At this stage, the code is known to work and has been thoroughly tested

This commit is contained in:
Jason Thistlethwaite
2026-04-27 22:24:04 +00:00
parent a11fa3b142
commit 16feb51b12
15 changed files with 2899 additions and 0 deletions
+658
View File
@@ -0,0 +1,658 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/VoipMsClient.php';
use VoipMs\VoipMsClient;
use VoipMs\VoipMsResponse;
const MCP_PROTOCOL_VERSION = '2025-06-18';
const MCP_SESSION_ID = 'voipms-test-session';
if (PHP_SAPI === 'cli-server') {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if (!in_array($path, ['/', '/mcp', '/sse', '/message', '/health'], true)) {
http_response_code(404);
echo "Not found\n";
return;
}
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
sendCorsHeaders();
http_response_code(204);
return;
}
if ((parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/') === '/health') {
sendJson(['status' => '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 <token>.', 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 <token>.', 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<string, mixed> $request
* @return array<string, mixed>|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<string, mixed> $params
* @return array<string, mixed>
*/
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<string, mixed> $params
* @return array<string, mixed>
*/
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<string, mixed> $arguments
* @return array<string, mixed>
*/
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<array<string, mixed>>
*/
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<string, mixed> $properties
* @param list<string> $required
* @return array<string, mixed>
*/
function objectSchema(array $properties, array $required = []): array
{
return [
'type' => 'object',
'properties' => $properties,
'required' => $required,
'additionalProperties' => false,
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
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<string, mixed> $arguments
* @return array<string, mixed>
*/
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<string, mixed> $arguments
* @return array<string, string>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $arguments
*/
function stringArg(array $arguments, string $key, string $default): string
{
return optionalStringArg($arguments, $key) ?? $default;
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed>
*/
function jsonRpcResult(mixed $id, mixed $result): array
{
return [
'jsonrpc' => '2.0',
'id' => $id,
'result' => $result,
];
}
/**
* @return array<string, mixed>
*/
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();
}