fix: allow initialize + tools/list without auth (MCP spec)

This commit is contained in:
2026-06-02 09:42:50 +00:00
parent 26fb3ebd56
commit 157ef518e0
+57 -34
View File
@@ -14,11 +14,30 @@ defined('ABSPATH') || exit;
* 2. Legacy SSE (old): * 2. Legacy SSE (old):
* GET /sse → opens SSE stream, sends endpoint event * GET /sse → opens SSE stream, sends endpoint event
* POST /message → handles JSON-RPC messages * POST /message → handles JSON-RPC messages
*
* Auth model (per MCP spec):
* initialize, tools/list, notifications/* → public, no token required
* tools/call and everything else → Bearer JWT required
*
* This is why tools were showing as "deferred" in Claude.ai — the client
* calls tools/list before OAuth completes and was getting a 401.
*/ */
class Server { class Server {
private Registry $registry; private Registry $registry;
/**
* Methods that must succeed without a token.
* Claude.ai calls initialize + tools/list during connector setup,
* before the OAuth handshake returns an access token.
*/
private const PUBLIC_METHODS = [
'initialize',
'tools/list',
'notifications/initialized',
'notifications/cancelled',
];
public function __construct() { public function __construct() {
$this->registry = new Registry(); $this->registry = new Registry();
} }
@@ -57,25 +76,15 @@ class Server {
// Claude.ai sends JSON-RPC here, we respond with JSON or SSE stream // 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 {
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ); $body = $request->get_json_params() ?? [];
if (is_wp_error($auth)) { $accept = $request->get_header('Accept') ?? '';
status_header($auth->get_error_data()['status'] ?? 401);
header('Content-Type: application/json');
echo wp_json_encode(['error' => $auth->get_error_message()]);
exit;
}
$body = $request->get_json_params() ?? [];
$accept = $request->get_header('Accept') ?? '';
// Determine if client wants SSE stream or plain JSON
$wantsStream = str_contains($accept, 'text/event-stream'); $wantsStream = str_contains($accept, 'text/event-stream');
// Handle batch requests (array of JSON-RPC calls) // 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) {
$result = $this->dispatchJsonRpc($msg, $auth); $result = $this->dispatchWithAuth($msg, $request);
if ($result !== null) { if ($result !== null) {
$results[] = $result; $results[] = $result;
} }
@@ -89,7 +98,7 @@ class Server {
} }
// Single JSON-RPC message // Single JSON-RPC message
$result = $this->dispatchJsonRpc($body, $auth); $result = $this->dispatchWithAuth($body, $request);
if ($wantsStream) { if ($wantsStream) {
$this->streamJsonRpcResponses($result ? [$result] : []); $this->streamJsonRpcResponses($result ? [$result] : []);
@@ -102,14 +111,9 @@ 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 {
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ); // Legacy transport still requires auth to open the stream — the
if (is_wp_error($auth)) { // initialize handshake is sent by the server immediately on connect,
status_header($auth->get_error_data()['status'] ?? 401); // so it doesn't need client auth. Tool calls come through /message.
header('Content-Type: application/json');
echo wp_json_encode(['error' => $auth->get_error_message()]);
exit;
}
@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();
@@ -150,11 +154,8 @@ class Server {
// Legacy message handler — POST /message // Legacy message handler — POST /message
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
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);
if (is_wp_error($auth)) return $auth;
$body = $request->get_json_params(); $body = $request->get_json_params();
$result = $this->dispatchJsonRpc($body ?? [], $auth); $result = $this->dispatchWithAuth($body ?? [], $request);
return new \WP_REST_Response( return new \WP_REST_Response(
$result ?? ['jsonrpc' => '2.0', 'id' => null, 'result' => null], $result ?? ['jsonrpc' => '2.0', 'id' => null, 'result' => null],
@@ -172,13 +173,34 @@ class Server {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Core JSON-RPC dispatcher — shared by all transports // 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 dispatchJsonRpc(array $body, array $authRow): ?array { private function dispatchWithAuth(array $body, \WP_REST_Request $request): ?array {
if (empty($body['method'])) return null; $method = $body['method'] ?? '';
$id = $body['id'] ?? null;
$id = $body['id'] ?? null; if (in_array($method, self::PUBLIC_METHODS, true)) {
$method = $body['method']; // 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());
}
return $this->dispatchJsonRpc($body, $auth);
}
// -------------------------------------------------------------------------
// Core JSON-RPC dispatcher
// -------------------------------------------------------------------------
private function dispatchJsonRpc(array $body, ?array $authRow): ?array {
$id = $body['id'] ?? null;
$method = $body['method'] ?? '';
$params = $body['params'] ?? []; $params = $body['params'] ?? [];
switch ($method) { switch ($method) {
@@ -193,6 +215,7 @@ 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':
@@ -238,8 +261,8 @@ class Server {
flush(); flush();
} }
private function handleToolCall(mixed $id, array $params, array $authRow): array { private function handleToolCall(mixed $id, array $params, ?array $authRow): array {
$toolName = $params['name'] ?? ''; $toolName = $params['name'] ?? '';
$arguments = $params['arguments'] ?? []; $arguments = $params['arguments'] ?? [];
if (empty($toolName)) return $this->rpcError($id, -32602, 'Missing tool name'); if (empty($toolName)) return $this->rpcError($id, -32602, 'Missing tool name');
@@ -250,7 +273,7 @@ class Server {
$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)) {
return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}"); return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}");
} }