fix: SSE keep-alive loop 270s + retry hint + disable output buffering properly

This commit is contained in:
2026-05-08 21:51:36 +00:00
parent f5d82ac3dd
commit 314fac44fe
+44 -72
View File
@@ -5,16 +5,11 @@ namespace AskTamMCP\MCP;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* MCP Server — handles the SSE connection and JSON-RPC message dispatch. * MCP Server — SSE transport + JSON-RPC dispatch.
* *
* MCP protocol flow: * SSE strategy: send endpoint + initialize response immediately, then
* 1. Client connects to /sse → server sends "endpoint" event with POST URL * hold the connection with pings for as long as PHP allows.
* 2. Client POSTs JSON-RPC messages to /message * Claude.ai reconnects automatically on disconnect (SSE spec).
* 3. Server sends "message" SSE events back with JSON-RPC responses
*
* This implementation uses a stateless synchronous approach:
* - SSE endpoint performs initialize + tools/list inline
* - /message endpoint handles individual tool calls synchronously
*/ */
class Server { class Server {
@@ -25,21 +20,18 @@ class Server {
} }
public function registerRoutes(): void { public function registerRoutes(): void {
// SSE stream endpoint — AI client connects here
register_rest_route('asktam-mcp/v1', '/sse', [ register_rest_route('asktam-mcp/v1', '/sse', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'handleSse'], 'callback' => [$this, 'handleSse'],
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Message endpoint — AI client POSTs JSON-RPC here
register_rest_route('asktam-mcp/v1', '/message', [ register_rest_route('asktam-mcp/v1', '/message', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [$this, 'handleMessage'], 'callback' => [$this, 'handleMessage'],
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Simple info endpoint
register_rest_route('asktam-mcp/v1', '/info', [ register_rest_route('asktam-mcp/v1', '/info', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'handleInfo'], 'callback' => [$this, 'handleInfo'],
@@ -47,9 +39,6 @@ class Server {
]); ]);
} }
/**
* SSE endpoint — opens the connection and sends server capabilities.
*/
public function handleSse(\WP_REST_Request $request): void { public function handleSse(\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)) {
@@ -59,51 +48,58 @@ class Server {
exit; 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 // SSE headers
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream; charset=UTF-8');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache, no-store');
header('X-Accel-Buffering: no'); header('X-Accel-Buffering: no'); // nginx
header('Connection: keep-alive'); header('Connection: keep-alive');
ob_end_flush(); header('Access-Control-Allow-Origin: *');
$messageEndpoint = rest_url('asktam-mcp/v1/message'); $messageEndpoint = rest_url('asktam-mcp/v1/message');
// Send endpoint event — tells client where to POST messages // 1. Send endpoint event
$this->sseEvent('endpoint', $messageEndpoint); $this->sseEvent('endpoint', $messageEndpoint);
// Send server capabilities (MCP initialize response) // 2. Send initialize response
$initResponse = [ $this->sseEvent('message', wp_json_encode([
'jsonrpc' => '2.0', 'jsonrpc' => '2.0',
'id' => null, 'id' => null,
'result' => [ 'result' => [
'protocolVersion' => '2024-11-05', 'protocolVersion' => '2024-11-05',
'capabilities' => [ 'capabilities' => ['tools' => ['listChanged' => false]],
'tools' => ['listChanged' => false], 'serverInfo' => ['name' => 'advanced-mcp-plugin', 'version' => ASKTAM_MCP_VERSION],
],
'serverInfo' => [
'name' => 'advanced-mcp-plugin',
'version' => ASKTAM_MCP_VERSION,
],
], ],
]; ]));
$this->sseEvent('message', wp_json_encode($initResponse));
// Keep-alive ping loop (max 29 seconds to avoid WP timeout) // 3. Keep-alive loop — ping every 15s, max 5 min
$start = time(); // Claude.ai auto-reconnects per SSE spec when connection drops
while ((time() - $start) < 25) { $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());
if (connection_aborted()) { // Flush and sleep in small increments to catch aborts quickly
break; for ($i = 0; $i < 15; $i++) {
if (connection_aborted()) break 2;
sleep(1);
} }
sleep(5);
} }
// Send retry hint so Claude.ai reconnects immediately
echo "retry: 1000\n\n";
flush();
exit; exit;
} }
/**
* Message endpoint — receives JSON-RPC, dispatches to tool handler, returns result.
*/
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)) {
@@ -129,16 +125,13 @@ class Server {
]); ]);
case 'tools/list': case 'tools/list':
return $this->rpcResult($id, [ return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]);
'tools' => $this->registry->getToolDefinitions(),
]);
case 'tools/call': case 'tools/call':
return $this->handleToolCall($id, $params, $auth); return $this->handleToolCall($id, $params, $auth);
case 'notifications/initialized': case 'notifications/initialized':
case 'notifications/cancelled': case 'notifications/cancelled':
// Client notifications — acknowledge silently
return new \WP_REST_Response(null, 204); return new \WP_REST_Response(null, 204);
default: default:
@@ -146,22 +139,14 @@ class Server {
} }
} }
/**
* Info endpoint — public, returns plugin version + endpoint URL.
*/
public function handleInfo(): \WP_REST_Response { public function handleInfo(): \WP_REST_Response {
return new \WP_REST_Response([ return new \WP_REST_Response([
'name' => 'Advanced MCP Plugin by AskTam', 'name' => 'Advanced MCP Plugin by AskTam',
'version' => ASKTAM_MCP_VERSION, 'version' => ASKTAM_MCP_VERSION,
'endpoint' => rest_url('asktam-mcp/v1/sse'), 'endpoint' => rest_url('asktam-mcp/v1/sse'),
'docs' => 'https://asktam.net/mcp-plugin',
]); ]);
} }
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
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'] ?? [];
@@ -175,15 +160,11 @@ class Server {
return $this->rpcError($id, -32601, "Unknown tool: {$toolName}"); return $this->rpcError($id, -32601, "Unknown tool: {$toolName}");
} }
// Check write/admin scope for mutating tools // Scope check for write/admin tools
$tool = $this->registry->getTool($toolName); $tool = $this->registry->getTool($toolName);
if (($tool['scope'] ?? 'read') !== 'read') { $required = $tool['scope'] ?? 'read';
$scopeCheck = \AskTamMCP\Core\Auth::validate( if ($required !== 'read') {
new \WP_REST_Request(), // dummy — scopes already on $authRow
$tool['scope']
);
$keyScopes = array_map('trim', explode(',', $authRow['scopes'])); $keyScopes = array_map('trim', explode(',', $authRow['scopes']));
$required = $tool['scope'];
if (!in_array($required, $keyScopes, true) && !in_array('admin', $keyScopes, true)) { if (!in_array($required, $keyScopes, true) && !in_array('admin', $keyScopes, true)) {
return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}"); return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}");
} }
@@ -202,24 +183,15 @@ class Server {
} }
private function rpcResult(mixed $id, mixed $result): \WP_REST_Response { private function rpcResult(mixed $id, mixed $result): \WP_REST_Response {
return new \WP_REST_Response([ return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]);
'jsonrpc' => '2.0',
'id' => $id,
'result' => $result,
]);
} }
private function rpcError(mixed $id, int $code, string $message): \WP_REST_Response { private function rpcError(mixed $id, int $code, string $message): \WP_REST_Response {
return new \WP_REST_Response([ return new \WP_REST_Response(['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $message]], 200);
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => $code, 'message' => $message],
], 200); // MCP errors use HTTP 200 with JSON-RPC error body
} }
private function sseEvent(string $event, string $data): void { private function sseEvent(string $event, string $data): void {
echo "event: {$event}\n"; echo "event: {$event}\ndata: {$data}\n\n";
echo "data: {$data}\n\n";
flush(); flush();
} }
} }