'ok', 'service' => 'voipms-mcp']); return; } $requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; if ($requestMethod === 'GET' && ($requestPath === '/mcp' || $requestPath === '/sse')) { if (!isAuthorized()) { sendJsonError(null, -32001, 'Unauthorized. Set MCP_AUTH_TOKEN and send Authorization: Bearer .', 401); return; } sendSseHandshake($requestPath === '/sse'); return; } if ($requestMethod === 'DELETE' && $requestPath === '/mcp') { http_response_code(204); return; } if ($requestMethod !== 'POST') { sendJsonError(null, -32600, 'MCP endpoint expects POST JSON-RPC requests.', 405); return; } if (!isAuthorized()) { sendJsonError(null, -32001, 'Unauthorized. Set MCP_AUTH_TOKEN and send Authorization: Bearer .', 401); return; } $payload = json_decode(file_get_contents('php://input') ?: '', true); if (json_last_error() !== JSON_ERROR_NONE) { sendJsonError(null, -32700, 'Invalid JSON request.'); return; } try { if (isBatchRequest($payload)) { $responses = []; foreach ($payload as $request) { $response = handleJsonRpcRequest(is_array($request) ? $request : []); if ($response !== null) { $responses[] = $response; } } sendJson($responses); return; } $response = handleJsonRpcRequest(is_array($payload) ? $payload : []); if ($response === null) { sendCorsHeaders(); header('Mcp-Session-Id: ' . MCP_SESSION_ID); http_response_code(202); return; } sendJson($response); } catch (Throwable $exception) { sendJson(jsonRpcError($payload['id'] ?? null, -32603, $exception->getMessage())); } /** * @param array $request * @return array|null */ function handleJsonRpcRequest(array $request): ?array { $id = $request['id'] ?? null; $method = is_string($request['method'] ?? null) ? $request['method'] : ''; $params = is_array($request['params'] ?? null) ? $request['params'] : []; if ($method === '') { return jsonRpcError($id, -32600, 'Missing JSON-RPC method.'); } if (str_starts_with($method, 'notifications/')) { return null; } return match ($method) { 'initialize' => initializeResult($id, $params), 'ping' => jsonRpcResult($id, new stdClass()), 'tools/list' => jsonRpcResult($id, ['tools' => toolDefinitions()]), 'tools/call' => handleToolCall($id, $params), default => jsonRpcError($id, -32601, "Unsupported method: {$method}"), }; } /** * @param array $params * @return array */ function handleToolCall(mixed $id, array $params): array { $name = is_string($params['name'] ?? null) ? $params['name'] : ''; $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : []; if ($name === '') { return jsonRpcError($id, -32602, 'tools/call requires a tool name.'); } try { $result = callTool($name, $arguments); return jsonRpcResult($id, [ 'content' => [ [ 'type' => 'text', 'text' => json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), ], ], 'isError' => false, ]); } catch (Throwable $exception) { return jsonRpcResult($id, [ 'content' => [ [ 'type' => 'text', 'text' => $exception->getMessage(), ], ], 'isError' => true, ]); } } /** * @param array $params * @return array */ function initializeResult(mixed $id, array $params): array { $requestedVersion = is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : MCP_PROTOCOL_VERSION; $supportedVersions = ['2024-11-05', '2025-03-26', '2025-06-18']; $protocolVersion = in_array($requestedVersion, $supportedVersions, true) ? $requestedVersion : MCP_PROTOCOL_VERSION; header('Mcp-Session-Id: ' . MCP_SESSION_ID); return jsonRpcResult($id, [ 'protocolVersion' => $protocolVersion, 'capabilities' => [ 'tools' => [ 'listChanged' => false, ], ], 'serverInfo' => [ 'name' => 'voipms-mcp', 'version' => '0.1.0', ], ]); } /** * @param array $arguments * @return array */ function callTool(string $name, array $arguments): array { $client = voipmsClient(); return match ($name) { 'voipms_get_balance' => responsePayload($client->getBalance(boolArg($arguments, 'advanced', false))), 'voipms_get_phonebook_groups' => responsePayload($client->getPhonebookGroups( optionalStringArg($arguments, 'group_id'), optionalStringArg($arguments, 'name'), )), 'voipms_get_phonebook_entries' => responsePayload($client->getPhonebook( optionalStringArg($arguments, 'entry_id'), optionalStringArg($arguments, 'name'), optionalStringArg($arguments, 'group_id'), optionalStringArg($arguments, 'group_name'), )), 'voipms_get_callerid_filters' => responsePayload($client->getCallerIdFiltering(optionalStringArg($arguments, 'filtering_id'))), 'voipms_get_accounts' => responsePayload($client->getAccounts(optionalStringArg($arguments, 'client'))), 'voipms_get_call_accounts' => responsePayload($client->getCallAccounts(optionalStringArg($arguments, 'client'))), 'voipms_get_call_recordings' => responsePayload($client->getCallRecordings( requiredStringArg($arguments, 'account'), requiredStringArg($arguments, 'date_from'), requiredStringArg($arguments, 'date_to'), stringArg($arguments, 'call_type', 'all'), optionalStringArg($arguments, 'start'), optionalStringArg($arguments, 'length'), )), 'voipms_get_call_recording' => responsePayload($client->getCallRecording( requiredStringArg($arguments, 'account'), requiredStringArg($arguments, 'callrecording'), )), 'voipms_send_call_recording_email' => responsePayload($client->sendCallRecordingEmail( requiredStringArg($arguments, 'account'), requiredStringArg($arguments, 'email'), requiredStringArg($arguments, 'callrecording'), )), 'voipms_get_call_transcript' => [ 'status' => 'not_configured', 'message' => 'Transcript storage/transcription provider has not been implemented yet. Use voipms_get_call_recordings and voipms_get_call_recording for recording access.', 'callrecording' => optionalStringArg($arguments, 'callrecording'), ], 'voipms_get_recent_cdr' => responsePayload($client->getCdr( requiredStringArg($arguments, 'date_from'), requiredStringArg($arguments, 'date_to'), stringArg($arguments, 'timezone', getenv('VOIPMS_TIMEZONE') ?: '-4'), cdrFilters($arguments), )), 'voipms_add_number_to_phonebook_group' => addNumberToPhonebookGroupTool($client, $arguments), 'voipms_remove_number_from_phonebook_group' => removeNumberFromPhonebookGroupTool($client, $arguments), default => throw new InvalidArgumentException("Unknown tool: {$name}"), }; } /** * @return list> */ function toolDefinitions(): array { return [ [ 'name' => 'voipms_get_balance', 'description' => 'Get the VoIP.ms account balance.', 'inputSchema' => objectSchema([ 'advanced' => ['type' => 'boolean', 'description' => 'Include advanced balance and call statistics.'], ]), ], [ 'name' => 'voipms_get_phonebook_groups', 'description' => 'List VoIP.ms phonebook groups, optionally filtered by group ID or name.', 'inputSchema' => objectSchema([ 'group_id' => ['type' => 'string'], 'name' => ['type' => 'string'], ]), ], [ 'name' => 'voipms_get_phonebook_entries', 'description' => 'List VoIP.ms phonebook entries, optionally filtered by entry ID, name, group ID, or group name.', 'inputSchema' => objectSchema([ 'entry_id' => ['type' => 'string'], 'name' => ['type' => 'string'], 'group_id' => ['type' => 'string'], 'group_name' => ['type' => 'string'], ]), ], [ 'name' => 'voipms_get_callerid_filters', 'description' => 'List VoIP.ms CallerID Filtering rules, optionally filtered by filtering ID.', 'inputSchema' => objectSchema([ 'filtering_id' => ['type' => 'string'], ]), ], [ 'name' => 'voipms_get_accounts', 'description' => 'Friendly alias for voipms_get_call_accounts. Lists account filter values for CDR and call recording tools; it does not list VoIP.ms login users.', 'inputSchema' => objectSchema([ 'client' => ['type' => 'string', 'description' => 'Optional reseller client ID.'], ]), ], [ 'name' => 'voipms_get_call_accounts', 'description' => 'List account filter values accepted by CDR and call recording tools. Use the returned value field as the account argument.', 'inputSchema' => objectSchema([ 'client' => ['type' => 'string', 'description' => 'Optional reseller client ID.'], ]), ], [ 'name' => 'voipms_get_call_recordings', 'description' => 'List VoIP.ms call recordings for an account and date range. Use voipms_get_accounts to discover account values first.', 'inputSchema' => objectSchema([ 'account' => ['type' => 'string', 'description' => 'Account filter value, often all or a subaccount value from voipms_get_accounts.'], 'date_from' => ['type' => 'string', 'description' => 'Start date, YYYY-MM-DD.'], 'date_to' => ['type' => 'string', 'description' => 'End date, YYYY-MM-DD.'], 'call_type' => ['type' => 'string', 'description' => 'all, incoming, or outgoing. Default all.'], 'start' => ['type' => 'string', 'description' => 'Pagination start offset.'], 'length' => ['type' => 'string', 'description' => 'Pagination length.'], ], ['account', 'date_from', 'date_to']), ], [ 'name' => 'voipms_get_call_recording', 'description' => 'Retrieve one VoIP.ms call recording. The response may contain base64 audio data from VoIP.ms.', 'inputSchema' => objectSchema([ 'account' => ['type' => 'string'], 'callrecording' => ['type' => 'string', 'description' => 'Recording identifier returned by voipms_get_call_recordings.'], ], ['account', 'callrecording']), ], [ 'name' => 'voipms_send_call_recording_email', 'description' => 'Ask VoIP.ms to email one call recording.', 'inputSchema' => objectSchema([ 'account' => ['type' => 'string'], 'email' => ['type' => 'string'], 'callrecording' => ['type' => 'string', 'description' => 'Recording identifier returned by voipms_get_call_recordings.'], ], ['account', 'email', 'callrecording']), ], [ 'name' => 'voipms_get_call_transcript', 'description' => 'Placeholder transcript lookup. Transcription storage/provider is not configured yet.', 'inputSchema' => objectSchema([ 'callrecording' => ['type' => 'string'], ]), ], [ 'name' => 'voipms_get_recent_cdr', 'description' => 'Get call detail records for a date range. Defaults to all call statuses.', 'inputSchema' => objectSchema([ 'date_from' => ['type' => 'string', 'description' => 'Start date, YYYY-MM-DD.'], 'date_to' => ['type' => 'string', 'description' => 'End date, YYYY-MM-DD.'], 'timezone' => ['type' => 'string', 'description' => 'VoIP.ms timezone offset, for example -4.'], 'calltype' => ['type' => 'string', 'description' => 'Call type filter, such as incoming, outgoing, or a DID value from getCallTypes.'], 'callbilling' => ['type' => 'string', 'description' => 'Billing filter value.'], 'account' => ['type' => 'string', 'description' => 'Account filter value from getCallAccounts.'], 'answered' => ['type' => 'boolean'], 'noanswer' => ['type' => 'boolean'], 'busy' => ['type' => 'boolean'], 'failed' => ['type' => 'boolean'], ], ['date_from', 'date_to']), ], [ 'name' => 'voipms_add_number_to_phonebook_group', 'description' => 'Idempotently add a number to a named phonebook group. Defaults to dry_run=true for safety.', 'inputSchema' => objectSchema([ 'number' => ['type' => 'string'], 'group_name' => ['type' => 'string'], 'name' => ['type' => 'string'], 'note' => ['type' => 'string'], 'callerid' => ['type' => 'string'], 'dry_run' => ['type' => 'boolean', 'description' => 'When true, return the planned action without changing VoIP.ms. Default true.'], ], ['number', 'group_name']), ], [ 'name' => 'voipms_remove_number_from_phonebook_group', 'description' => 'Remove all matching phonebook entries for a number from a group. Defaults to dry_run=true for safety.', 'inputSchema' => objectSchema([ 'group_id' => ['type' => 'string'], 'number' => ['type' => 'string'], 'delete_matched_entries' => ['type' => 'boolean', 'description' => 'Also delete matched phonebook entries from the account.'], 'dry_run' => ['type' => 'boolean', 'description' => 'When true, return the planned action without changing VoIP.ms. Default true.'], ], ['group_id', 'number']), ], ]; } /** * @param array $properties * @param list $required * @return array */ function objectSchema(array $properties, array $required = []): array { return [ 'type' => 'object', 'properties' => $properties, 'required' => $required, 'additionalProperties' => false, ]; } /** * @param array $arguments * @return array */ function addNumberToPhonebookGroupTool(VoipMsClient $client, array $arguments): array { $dryRun = boolArg($arguments, 'dry_run', true); $number = requiredStringArg($arguments, 'number'); $groupName = requiredStringArg($arguments, 'group_name'); if ($dryRun) { return [ 'dry_run' => true, 'planned_action' => 'Add number to phonebook group if it is not already a member.', 'number' => $number, 'group_name' => $groupName, 'name' => optionalStringArg($arguments, 'name'), 'note' => optionalStringArg($arguments, 'note'), 'callerid' => optionalStringArg($arguments, 'callerid'), ]; } $result = $client->addNumberToPhonebookGroup( $number, $groupName, optionalStringArg($arguments, 'name'), optionalStringArg($arguments, 'note'), optionalStringArg($arguments, 'callerid'), ); return [ 'dry_run' => false, 'changed' => $result->changed(), 'group' => $result->group(), 'added_entry_ids' => $result->addedEntryIds(), 'existing_entry_ids' => $result->existingEntryIds(), 'member_ids' => $result->memberIds(), 'matching_entries' => $result->matchingEntries(), 'phonebook_response' => $result->phonebookResponse()?->data(), 'group_response' => $result->groupResponse()?->data(), ]; } /** * @param array $arguments * @return array */ function removeNumberFromPhonebookGroupTool(VoipMsClient $client, array $arguments): array { $dryRun = boolArg($arguments, 'dry_run', true); $groupId = requiredStringArg($arguments, 'group_id'); $number = requiredStringArg($arguments, 'number'); $deleteMatchedEntries = boolArg($arguments, 'delete_matched_entries', false); if ($dryRun) { return [ 'dry_run' => true, 'planned_action' => 'Remove every matching phonebook entry ID for this number from the group membership list.', 'group_id' => $groupId, 'number' => $number, 'delete_matched_entries' => $deleteMatchedEntries, ]; } $result = $client->removePhonebookNumberFromGroup($groupId, $number, $deleteMatchedEntries); return [ 'dry_run' => false, 'group_id' => $result->groupId(), 'group_name' => $result->groupName(), 'removed_entry_ids' => $result->removedEntryIds(), 'remaining_entry_ids' => $result->remainingEntryIds(), 'matched_entries' => $result->matchedEntries(), 'response' => $result->response()->data(), ]; } /** * @param array $arguments * @return array */ function cdrFilters(array $arguments): array { $filters = []; foreach (['calltype', 'callbilling', 'account'] as $field) { $value = optionalStringArg($arguments, $field); if ($value !== null) { $filters[$field] = $value; } } foreach (['answered', 'noanswer', 'busy', 'failed'] as $field) { if (array_key_exists($field, $arguments)) { $filters[$field] = boolArg($arguments, $field, false) ? '1' : '0'; } } return $filters; } /** * @return array */ function responsePayload(VoipMsResponse $response): array { return [ 'http_code' => $response->httpCode(), 'status' => $response->status(), 'message' => $response->message(), 'data' => $response->data(), ]; } function voipmsClient(): VoipMsClient { $username = getenv('VOIPMS_API_USERNAME') ?: ''; $password = getenv('VOIPMS_API_PASSWORD') ?: ''; $endpoint = getenv('VOIPMS_API_ENDPOINT') ?: VoipMsClient::DEFAULT_ENDPOINT; if ($username === '' || $password === '') { throw new RuntimeException('Missing VOIPMS_API_USERNAME or VOIPMS_API_PASSWORD in the MCP server environment.'); } return new VoipMsClient($username, $password, $endpoint); } /** * @param array $arguments */ function requiredStringArg(array $arguments, string $key): string { $value = optionalStringArg($arguments, $key); if ($value === null) { throw new InvalidArgumentException("Missing required argument: {$key}"); } return $value; } /** * @param array $arguments */ function stringArg(array $arguments, string $key, string $default): string { return optionalStringArg($arguments, $key) ?? $default; } /** * @param array $arguments */ function optionalStringArg(array $arguments, string $key): ?string { if (!array_key_exists($key, $arguments) || $arguments[$key] === null || $arguments[$key] === '') { return null; } if (is_scalar($arguments[$key])) { return (string) $arguments[$key]; } throw new InvalidArgumentException("Argument {$key} must be a string-compatible value."); } /** * @param array $arguments */ function boolArg(array $arguments, string $key, bool $default): bool { if (!array_key_exists($key, $arguments)) { return $default; } return filter_var($arguments[$key], FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default; } function isAuthorized(): bool { if ((getenv('MCP_ALLOW_UNAUTHENTICATED') ?: '') === '1') { return true; } $expected = getenv('MCP_AUTH_TOKEN') ?: ''; if ($expected === '') { return false; } $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (!is_string($authorization) || !str_starts_with($authorization, 'Bearer ')) { $queryToken = $_GET['token'] ?? ''; return is_string($queryToken) && hash_equals($expected, $queryToken); } return hash_equals($expected, substr($authorization, 7)); } /** * @param mixed $payload */ function isBatchRequest(mixed $payload): bool { return is_array($payload) && array_is_list($payload); } /** * @return array */ function jsonRpcResult(mixed $id, mixed $result): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ]; } /** * @return array */ function jsonRpcError(mixed $id, int $code, string $message): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => [ 'code' => $code, 'message' => $message, ], ]; } function sendJsonError(mixed $id, int $code, string $message, int $httpCode = 400): void { http_response_code($httpCode); sendJson(jsonRpcError($id, $code, $message)); } function sendJson(mixed $payload): void { sendCorsHeaders(); header('Mcp-Session-Id: ' . MCP_SESSION_ID); header('Content-Type: application/json'); echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } function sendCorsHeaders(): void { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, OPTIONS, GET, DELETE'); header('Access-Control-Allow-Headers: Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-ID'); header('Access-Control-Expose-Headers: Mcp-Session-Id'); } function sendSseHandshake(bool $legacyEndpointEvent): void { sendCorsHeaders(); header('Mcp-Session-Id: ' . MCP_SESSION_ID); header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); if ($legacyEndpointEvent) { echo "event: endpoint\n"; echo "data: /mcp\n\n"; } else { echo ": voipms-mcp stream open\n\n"; } echo "event: ping\n"; echo 'data: ' . json_encode(['time' => gmdate('c')], JSON_UNESCAPED_SLASHES) . "\n\n"; @ob_flush(); flush(); }