fix: accept POST /sse for Claude.ai streamable HTTP transport (2025-03-26 spec)

This commit is contained in:
2026-05-08 21:58:25 +00:00
parent 314fac44fe
commit 068a1d8bf8
+73 -38
View File
@@ -5,11 +5,15 @@ namespace AskTamMCP\MCP;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* MCP Server — SSE transport + JSON-RPC dispatch. * MCP Server — Streamable HTTP transport (2025-03-26 spec).
* *
* SSE strategy: send endpoint + initialize response immediately, then * Claude.ai uses POST /sse for all communication (not GET SSE stream).
* hold the connection with pings for as long as PHP allows. * Each POST carries a JSON-RPC message and gets a direct JSON response.
* Claude.ai reconnects automatically on disconnect (SSE spec). * We also keep GET /sse for legacy clients that expect SSE streams.
*
* POST /sse — Claude.ai sends JSON-RPC, gets JSON-RPC response
* GET /sse — legacy SSE stream for other MCP clients
* POST /message — explicit message endpoint (also supported)
*/ */
class Server { class Server {
@@ -20,12 +24,21 @@ class Server {
} }
public function registerRoutes(): void { public function registerRoutes(): void {
// SSE endpoint — GET for legacy stream, POST for Claude.ai streamable HTTP
register_rest_route('asktam-mcp/v1', '/sse', [ register_rest_route('asktam-mcp/v1', '/sse', [
'methods' => 'GET', [
'callback' => [$this, 'handleSse'], 'methods' => 'GET',
'permission_callback' => '__return_true', 'callback' => [$this, 'handleSseGet'],
'permission_callback' => '__return_true',
],
[
'methods' => 'POST',
'callback' => [$this, 'handleSsePost'],
'permission_callback' => '__return_true',
],
]); ]);
// Explicit message endpoint
register_rest_route('asktam-mcp/v1', '/message', [ register_rest_route('asktam-mcp/v1', '/message', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [$this, 'handleMessage'], 'callback' => [$this, 'handleMessage'],
@@ -39,7 +52,23 @@ class Server {
]); ]);
} }
public function handleSse(\WP_REST_Request $request): void { // -------------------------------------------------------------------------
// POST /sse — Streamable HTTP transport (Claude.ai 2025+)
// Each request is a JSON-RPC message, response is JSON-RPC
// -------------------------------------------------------------------------
public function handleSsePost(\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;
}
return $this->dispatchJsonRpc($request, $auth);
}
// -------------------------------------------------------------------------
// GET /sse — Legacy SSE stream for non-Claude.ai clients
// -------------------------------------------------------------------------
public function handleSseGet(\WP_REST_Request $request): void {
$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)) {
status_header($auth->get_error_data()['status'] ?? 401); status_header($auth->get_error_data()['status'] ?? 401);
@@ -48,28 +77,19 @@ class Server {
exit; exit;
} }
// Extend execution time as much as possible
@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();
// Disable output buffering completely
while (ob_get_level()) {
ob_end_clean();
}
// SSE headers
header('Content-Type: text/event-stream; charset=UTF-8'); header('Content-Type: text/event-stream; charset=UTF-8');
header('Cache-Control: no-cache, no-store'); header('Cache-Control: no-cache, no-store');
header('X-Accel-Buffering: no'); // nginx header('X-Accel-Buffering: no');
header('Connection: keep-alive'); header('Connection: keep-alive');
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
$messageEndpoint = rest_url('asktam-mcp/v1/message'); $messageEndpoint = rest_url('asktam-mcp/v1/message');
// 1. Send endpoint event
$this->sseEvent('endpoint', $messageEndpoint); $this->sseEvent('endpoint', $messageEndpoint);
// 2. Send initialize response
$this->sseEvent('message', wp_json_encode([ $this->sseEvent('message', wp_json_encode([
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'id' => null, 'id' => null,
@@ -80,34 +100,58 @@ class Server {
], ],
])); ]));
// 3. Keep-alive loop — ping every 15s, max 5 min $start = time();
// Claude.ai auto-reconnects per SSE spec when connection drops while (!connection_aborted() && (time() - $start) < 270) {
$start = time();
$maxTime = 270; // 4.5 min — safely under most PHP/proxy limits
while (!connection_aborted() && (time() - $start) < $maxTime) {
$this->sseEvent('ping', (string) time()); $this->sseEvent('ping', (string) time());
// Flush and sleep in small increments to catch aborts quickly
for ($i = 0; $i < 15; $i++) { for ($i = 0; $i < 15; $i++) {
if (connection_aborted()) break 2; if (connection_aborted()) break 2;
sleep(1); sleep(1);
} }
} }
// Send retry hint so Claude.ai reconnects immediately
echo "retry: 1000\n\n"; echo "retry: 1000\n\n";
flush(); flush();
exit; exit;
} }
// -------------------------------------------------------------------------
// POST /message — explicit endpoint (same as POST /sse)
// -------------------------------------------------------------------------
public function handleMessage(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { public function handleMessage(\WP_REST_Request $request): \WP_REST_Response|\WP_Error {
$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 $auth; return $auth;
} }
return $this->dispatchJsonRpc($request, $auth);
}
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'),
'transport' => 'streamable-http',
'mcp_version' => '2025-03-26',
]);
}
// -------------------------------------------------------------------------
// Shared JSON-RPC dispatcher
// -------------------------------------------------------------------------
private function dispatchJsonRpc(\WP_REST_Request $request, array $auth): \WP_REST_Response {
$body = $request->get_json_params(); $body = $request->get_json_params();
if (empty($body)) {
// Claude.ai sometimes sends empty POST to initialize connection
// Return capabilities immediately
return $this->rpcResult(null, [
'protocolVersion' => '2024-11-05',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
]);
}
if (empty($body['method'])) { if (empty($body['method'])) {
return $this->rpcError(null, -32600, 'Invalid Request: missing method'); return $this->rpcError(null, -32600, 'Invalid Request: missing method');
} }
@@ -139,14 +183,6 @@ class Server {
} }
} }
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 { private function handleToolCall(mixed $id, array $params, array $authRow): \WP_REST_Response {
$toolName = $params['name'] ?? ''; $toolName = $params['name'] ?? '';
$arguments = $params['arguments'] ?? []; $arguments = $params['arguments'] ?? [];
@@ -160,9 +196,8 @@ class Server {
return $this->rpcError($id, -32601, "Unknown tool: {$toolName}"); return $this->rpcError($id, -32601, "Unknown tool: {$toolName}");
} }
// Scope check for write/admin tools $tool = $this->registry->getTool($toolName);
$tool = $this->registry->getTool($toolName); $required = $tool['scope'] ?? 'read';
$required = $tool['scope'] ?? 'read';
if ($required !== 'read') { if ($required !== 'read') {
$keyScopes = array_map('trim', explode(',', $authRow['scopes'])); $keyScopes = array_map('trim', explode(',', $authRow['scopes']));
if (!in_array($required, $keyScopes, true) && !in_array('admin', $keyScopes, true)) { if (!in_array($required, $keyScopes, true) && !in_array('admin', $keyScopes, true)) {