diff --git a/includes/MCP/Server.php b/includes/MCP/Server.php index cc3f2cd..be4bd89 100644 --- a/includes/MCP/Server.php +++ b/includes/MCP/Server.php @@ -57,6 +57,22 @@ class Server { ], ]); + // Clean streamable-HTTP endpoint (Claude.ai). POST = JSON-RPC (streamable HTTP); + // GET returns plain info JSON (never an event-stream) so clients classify this + // as streamable HTTP, not deprecated SSE. Same auth + handlers as /sse POST. + register_rest_route('asktam-mcp/v1', '/mcp', [ + [ + 'methods' => 'POST', + 'callback' => [$this, 'handleSsePost'], + 'permission_callback' => '__return_true', + ], + [ + 'methods' => 'GET', + 'callback' => [$this, 'handleInfo'], + 'permission_callback' => '__return_true', + ], + ]); + // Legacy message endpoint (used by old SSE transport) register_rest_route('asktam-mcp/v1', '/message', [ 'methods' => 'POST', @@ -73,14 +89,12 @@ class Server { // ------------------------------------------------------------------------- // Streamable HTTP transport — POST /sse - // Claude.ai sends JSON-RPC here, we respond with JSON or SSE stream // ------------------------------------------------------------------------- public function handleSsePost(\WP_REST_Request $request): void { $body = $request->get_json_params() ?? []; $accept = $request->get_header('Accept') ?? ''; $wantsStream = str_contains($accept, 'text/event-stream'); - // Handle batch requests (array of JSON-RPC calls) if (isset($body[0])) { $results = []; foreach ($body as $msg) { @@ -97,13 +111,21 @@ class Server { return; } - // Single JSON-RPC message $result = $this->dispatchWithAuth($body, $request); + // Notifications (no result) → 202 Accepted, no body + if ($result === null) { + while (ob_get_level()) ob_end_clean(); + http_response_code(202); + header('Content-Type: application/json; charset=UTF-8'); + header('Access-Control-Allow-Origin: *'); + exit; + } + if ($wantsStream) { - $this->streamJsonRpcResponses($result ? [$result] : []); + $this->streamJsonRpcResponses([$result]); } else { - $this->jsonResponse($result ?? new \stdClass()); + $this->jsonResponse($result); } } @@ -111,9 +133,6 @@ class Server { // Legacy SSE transport — GET /sse // ------------------------------------------------------------------------- public function handleSseGet(\WP_REST_Request $request): void { - // Legacy transport still requires auth to open the stream — the - // initialize handshake is sent by the server immediately on connect, - // so it doesn't need client auth. Tool calls come through /message. @set_time_limit(0); @ini_set('max_execution_time', 0); while (ob_get_level()) ob_end_clean(); @@ -124,13 +143,18 @@ class Server { header('Connection: keep-alive'); header('Access-Control-Allow-Origin: *'); + // Forward token query param so auth works on the /message endpoint + $token = sanitize_text_field($request->get_param('token') ?? ''); $messageEndpoint = rest_url('asktam-mcp/v1/message'); + if ($token) { + $messageEndpoint = add_query_arg('token', $token, $messageEndpoint); + } $this->sseEvent('endpoint', $messageEndpoint); $this->sseEvent('message', wp_json_encode([ 'jsonrpc' => '2.0', 'id' => null, 'result' => [ - 'protocolVersion' => '2024-11-05', + 'protocolVersion' => '2025-03-26', 'capabilities' => ['tools' => ['listChanged' => false]], 'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION], ], @@ -174,22 +198,18 @@ class Server { // ------------------------------------------------------------------------- // Auth-aware dispatcher - // Reads the method name first, then decides whether auth is needed. - // PUBLIC_METHODS bypass auth entirely. Everything else requires a valid JWT. // ------------------------------------------------------------------------- private function dispatchWithAuth(array $body, \WP_REST_Request $request): ?array { $method = $body['method'] ?? ''; $id = $body['id'] ?? null; if (in_array($method, self::PUBLIC_METHODS, true)) { - // No auth required — pass null as authRow return $this->dispatchJsonRpc($body, null); } - // All non-public methods require a valid Bearer token $auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ); if (is_wp_error($auth)) { - return $this->rpcError($id, -32001, $auth->get_error_message()); + $this->authFailureResponse($id, $auth->get_error_message()); } return $this->dispatchJsonRpc($body, $auth); @@ -206,7 +226,7 @@ class Server { switch ($method) { case 'initialize': return $this->rpcResult($id, [ - 'protocolVersion' => '2024-11-05', + 'protocolVersion' => '2025-03-26', 'capabilities' => ['tools' => ['listChanged' => false]], 'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION], ]); @@ -215,12 +235,11 @@ class Server { return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]); case 'tools/call': - // authRow is guaranteed non-null here (dispatchWithAuth enforces it) return $this->handleToolCall($id, $params, $authRow); case 'notifications/initialized': case 'notifications/cancelled': - return null; // notifications get no response + return null; default: return $this->rpcError($id, -32601, "Method not found: {$method}"); @@ -241,8 +260,6 @@ class Server { $this->sseEvent('message', wp_json_encode($response)); } - // Send done marker - echo "event: done\ndata: \n\n"; flush(); exit; } @@ -289,6 +306,17 @@ class Server { } } + private function authFailureResponse(mixed $id, string $message): void { + while (ob_get_level()) ob_end_clean(); + http_response_code(401); + header('WWW-Authenticate: Bearer realm="MCP"'); + header('Link: <' . get_site_url() . '/.well-known/oauth-authorization-server>; rel="oauth2-authorization-server-metadata"'); + header('Content-Type: application/json; charset=UTF-8'); + header('Access-Control-Allow-Origin: *'); + echo wp_json_encode(['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => -32001, 'message' => $message]]); + exit; + } + private function rpcResult(mixed $id, mixed $result): array { return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; }