Files
advanced-mcp-plugin/includes/MCP/Server.php
T

198 lines
6.9 KiB
PHP

<?php
namespace AskTamMCP\MCP;
defined('ABSPATH') || exit;
/**
* MCP Server — SSE transport + JSON-RPC dispatch.
*
* SSE strategy: send endpoint + initialize response immediately, then
* hold the connection with pings for as long as PHP allows.
* Claude.ai reconnects automatically on disconnect (SSE spec).
*/
class Server {
private Registry $registry;
public function __construct() {
$this->registry = new Registry();
}
public function registerRoutes(): void {
register_rest_route('asktam-mcp/v1', '/sse', [
'methods' => 'GET',
'callback' => [$this, 'handleSse'],
'permission_callback' => '__return_true',
]);
register_rest_route('asktam-mcp/v1', '/message', [
'methods' => 'POST',
'callback' => [$this, 'handleMessage'],
'permission_callback' => '__return_true',
]);
register_rest_route('asktam-mcp/v1', '/info', [
'methods' => 'GET',
'callback' => [$this, 'handleInfo'],
'permission_callback' => '__return_true',
]);
}
public function handleSse(\WP_REST_Request $request): void {
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ);
if (is_wp_error($auth)) {
status_header($auth->get_error_data()['status'] ?? 401);
header('Content-Type: application/json');
echo wp_json_encode(['error' => $auth->get_error_message()]);
exit;
}
// Extend execution time as much as possible
@set_time_limit(0);
@ini_set('max_execution_time', 0);
// Disable output buffering completely
while (ob_get_level()) {
ob_end_clean();
}
// SSE headers
header('Content-Type: text/event-stream; charset=UTF-8');
header('Cache-Control: no-cache, no-store');
header('X-Accel-Buffering: no'); // nginx
header('Connection: keep-alive');
header('Access-Control-Allow-Origin: *');
$messageEndpoint = rest_url('asktam-mcp/v1/message');
// 1. Send endpoint event
$this->sseEvent('endpoint', $messageEndpoint);
// 2. Send initialize response
$this->sseEvent('message', wp_json_encode([
'jsonrpc' => '2.0',
'id' => null,
'result' => [
'protocolVersion' => '2024-11-05',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
],
]));
// 3. Keep-alive loop — ping every 15s, max 5 min
// Claude.ai auto-reconnects per SSE spec when connection drops
$start = time();
$maxTime = 270; // 4.5 min — safely under most PHP/proxy limits
while (!connection_aborted() && (time() - $start) < $maxTime) {
$this->sseEvent('ping', (string) time());
// Flush and sleep in small increments to catch aborts quickly
for ($i = 0; $i < 15; $i++) {
if (connection_aborted()) break 2;
sleep(1);
}
}
// Send retry hint so Claude.ai reconnects immediately
echo "retry: 1000\n\n";
flush();
exit;
}
public function handleMessage(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ);
if (is_wp_error($auth)) {
return $auth;
}
$body = $request->get_json_params();
if (empty($body['method'])) {
return $this->rpcError(null, -32600, 'Invalid Request: missing method');
}
$id = $body['id'] ?? null;
$method = $body['method'];
$params = $body['params'] ?? [];
switch ($method) {
case 'initialize':
return $this->rpcResult($id, [
'protocolVersion' => '2024-11-05',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
]);
case 'tools/list':
return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]);
case 'tools/call':
return $this->handleToolCall($id, $params, $auth);
case 'notifications/initialized':
case 'notifications/cancelled':
return new \WP_REST_Response(null, 204);
default:
return $this->rpcError($id, -32601, "Method not found: {$method}");
}
}
public function handleInfo(): \WP_REST_Response {
return new \WP_REST_Response([
'name' => 'Advanced MCP Plugin by AskTam',
'version' => ASKTAM_MCP_VERSION,
'endpoint' => rest_url('asktam-mcp/v1/sse'),
]);
}
private function handleToolCall(mixed $id, array $params, array $authRow): \WP_REST_Response {
$toolName = $params['name'] ?? '';
$arguments = $params['arguments'] ?? [];
if (empty($toolName)) {
return $this->rpcError($id, -32602, 'Missing tool name');
}
$handler = $this->registry->getHandler($toolName);
if (!$handler) {
return $this->rpcError($id, -32601, "Unknown tool: {$toolName}");
}
// Scope check for write/admin tools
$tool = $this->registry->getTool($toolName);
$required = $tool['scope'] ?? 'read';
if ($required !== 'read') {
$keyScopes = array_map('trim', explode(',', $authRow['scopes']));
if (!in_array($required, $keyScopes, true) && !in_array('admin', $keyScopes, true)) {
return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}");
}
}
try {
$result = $handler($arguments);
return $this->rpcResult($id, [
'content' => [
['type' => 'text', 'text' => is_string($result) ? $result : wp_json_encode($result)],
],
]);
} catch (\Throwable $e) {
return $this->rpcError($id, -32000, $e->getMessage());
}
}
private function rpcResult(mixed $id, mixed $result): \WP_REST_Response {
return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]);
}
private function rpcError(mixed $id, int $code, string $message): \WP_REST_Response {
return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]], 200);
}
private function sseEvent(string $event, string $data): void {
echo "event: {$event}\ndata: {$data}\n\n";
flush();
}
}