MCP: add clean /mcp streamable-HTTP route (POST JSON-RPC, GET info) so Claude.ai stops classifying the connector as deprecated SSE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8t9GEyyLZbUQu26na1JQg
This commit is contained in:
+47
-19
@@ -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)
|
// Legacy message endpoint (used by old SSE transport)
|
||||||
register_rest_route('asktam-mcp/v1', '/message', [
|
register_rest_route('asktam-mcp/v1', '/message', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -73,14 +89,12 @@ class Server {
|
|||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Streamable HTTP transport — POST /sse
|
// 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 {
|
public function handleSsePost(\WP_REST_Request $request): void {
|
||||||
$body = $request->get_json_params() ?? [];
|
$body = $request->get_json_params() ?? [];
|
||||||
$accept = $request->get_header('Accept') ?? '';
|
$accept = $request->get_header('Accept') ?? '';
|
||||||
$wantsStream = str_contains($accept, 'text/event-stream');
|
$wantsStream = str_contains($accept, 'text/event-stream');
|
||||||
|
|
||||||
// Handle batch requests (array of JSON-RPC calls)
|
|
||||||
if (isset($body[0])) {
|
if (isset($body[0])) {
|
||||||
$results = [];
|
$results = [];
|
||||||
foreach ($body as $msg) {
|
foreach ($body as $msg) {
|
||||||
@@ -97,13 +111,21 @@ class Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single JSON-RPC message
|
|
||||||
$result = $this->dispatchWithAuth($body, $request);
|
$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) {
|
if ($wantsStream) {
|
||||||
$this->streamJsonRpcResponses($result ? [$result] : []);
|
$this->streamJsonRpcResponses([$result]);
|
||||||
} else {
|
} else {
|
||||||
$this->jsonResponse($result ?? new \stdClass());
|
$this->jsonResponse($result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +133,6 @@ class Server {
|
|||||||
// Legacy SSE transport — GET /sse
|
// Legacy SSE transport — GET /sse
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
public function handleSseGet(\WP_REST_Request $request): void {
|
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);
|
@set_time_limit(0);
|
||||||
@ini_set('max_execution_time', 0);
|
@ini_set('max_execution_time', 0);
|
||||||
while (ob_get_level()) ob_end_clean();
|
while (ob_get_level()) ob_end_clean();
|
||||||
@@ -124,13 +143,18 @@ class Server {
|
|||||||
header('Connection: keep-alive');
|
header('Connection: keep-alive');
|
||||||
header('Access-Control-Allow-Origin: *');
|
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');
|
$messageEndpoint = rest_url('asktam-mcp/v1/message');
|
||||||
|
if ($token) {
|
||||||
|
$messageEndpoint = add_query_arg('token', $token, $messageEndpoint);
|
||||||
|
}
|
||||||
$this->sseEvent('endpoint', $messageEndpoint);
|
$this->sseEvent('endpoint', $messageEndpoint);
|
||||||
$this->sseEvent('message', wp_json_encode([
|
$this->sseEvent('message', wp_json_encode([
|
||||||
'jsonrpc' => '2.0',
|
'jsonrpc' => '2.0',
|
||||||
'id' => null,
|
'id' => null,
|
||||||
'result' => [
|
'result' => [
|
||||||
'protocolVersion' => '2024-11-05',
|
'protocolVersion' => '2025-03-26',
|
||||||
'capabilities' => ['tools' => ['listChanged' => false]],
|
'capabilities' => ['tools' => ['listChanged' => false]],
|
||||||
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
|
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
|
||||||
],
|
],
|
||||||
@@ -174,22 +198,18 @@ class Server {
|
|||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Auth-aware dispatcher
|
// 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 {
|
private function dispatchWithAuth(array $body, \WP_REST_Request $request): ?array {
|
||||||
$method = $body['method'] ?? '';
|
$method = $body['method'] ?? '';
|
||||||
$id = $body['id'] ?? null;
|
$id = $body['id'] ?? null;
|
||||||
|
|
||||||
if (in_array($method, self::PUBLIC_METHODS, true)) {
|
if (in_array($method, self::PUBLIC_METHODS, true)) {
|
||||||
// No auth required — pass null as authRow
|
|
||||||
return $this->dispatchJsonRpc($body, null);
|
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);
|
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ);
|
||||||
if (is_wp_error($auth)) {
|
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);
|
return $this->dispatchJsonRpc($body, $auth);
|
||||||
@@ -206,7 +226,7 @@ class Server {
|
|||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'initialize':
|
case 'initialize':
|
||||||
return $this->rpcResult($id, [
|
return $this->rpcResult($id, [
|
||||||
'protocolVersion' => '2024-11-05',
|
'protocolVersion' => '2025-03-26',
|
||||||
'capabilities' => ['tools' => ['listChanged' => false]],
|
'capabilities' => ['tools' => ['listChanged' => false]],
|
||||||
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
|
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
|
||||||
]);
|
]);
|
||||||
@@ -215,12 +235,11 @@ class Server {
|
|||||||
return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]);
|
return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]);
|
||||||
|
|
||||||
case 'tools/call':
|
case 'tools/call':
|
||||||
// authRow is guaranteed non-null here (dispatchWithAuth enforces it)
|
|
||||||
return $this->handleToolCall($id, $params, $authRow);
|
return $this->handleToolCall($id, $params, $authRow);
|
||||||
|
|
||||||
case 'notifications/initialized':
|
case 'notifications/initialized':
|
||||||
case 'notifications/cancelled':
|
case 'notifications/cancelled':
|
||||||
return null; // notifications get no response
|
return null;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return $this->rpcError($id, -32601, "Method not found: {$method}");
|
return $this->rpcError($id, -32601, "Method not found: {$method}");
|
||||||
@@ -241,8 +260,6 @@ class Server {
|
|||||||
$this->sseEvent('message', wp_json_encode($response));
|
$this->sseEvent('message', wp_json_encode($response));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send done marker
|
|
||||||
echo "event: done\ndata: \n\n";
|
|
||||||
flush();
|
flush();
|
||||||
exit;
|
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 {
|
private function rpcResult(mixed $id, mixed $result): array {
|
||||||
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user