At this stage, the code is known to work and has been thoroughly tested
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user