fix: accept POST /sse for Claude.ai streamable HTTP transport (2025-03-26 spec)
This commit is contained in:
+68
-33
@@ -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',
|
'methods' => 'GET',
|
||||||
'callback' => [$this, 'handleSse'],
|
'callback' => [$this, 'handleSseGet'],
|
||||||
'permission_callback' => '__return_true',
|
'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
|
|
||||||
// Claude.ai auto-reconnects per SSE spec when connection drops
|
|
||||||
$start = time();
|
$start = time();
|
||||||
$maxTime = 270; // 4.5 min — safely under most PHP/proxy limits
|
while (!connection_aborted() && (time() - $start) < 270) {
|
||||||
|
|
||||||
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,7 +196,6 @@ 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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user