fix: allow initialize + tools/list without auth (MCP spec)
This commit is contained in:
+57
-34
@@ -14,11 +14,30 @@ defined('ABSPATH') || exit;
|
||||
* 2. Legacy SSE (old):
|
||||
* GET /sse → opens SSE stream, sends endpoint event
|
||||
* 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 {
|
||||
|
||||
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() {
|
||||
$this->registry = new Registry();
|
||||
}
|
||||
@@ -57,25 +76,15 @@ class Server {
|
||||
// Claude.ai sends JSON-RPC here, we respond with JSON or SSE stream
|
||||
// -------------------------------------------------------------------------
|
||||
public function handleSsePost(\WP_REST_Request $request): void {
|
||||
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ);
|
||||
if (is_wp_error($auth)) {
|
||||
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
|
||||
$body = $request->get_json_params() ?? [];
|
||||
$accept = $request->get_header('Accept') ?? '';
|
||||
$wantsStream = str_contains($accept, 'text/event-stream');
|
||||
|
||||
// Handle batch requests (array of JSON-RPC calls)
|
||||
if (isset($body[0])) {
|
||||
$results = [];
|
||||
foreach ($body as $msg) {
|
||||
$result = $this->dispatchJsonRpc($msg, $auth);
|
||||
$result = $this->dispatchWithAuth($msg, $request);
|
||||
if ($result !== null) {
|
||||
$results[] = $result;
|
||||
}
|
||||
@@ -89,7 +98,7 @@ class Server {
|
||||
}
|
||||
|
||||
// Single JSON-RPC message
|
||||
$result = $this->dispatchJsonRpc($body, $auth);
|
||||
$result = $this->dispatchWithAuth($body, $request);
|
||||
|
||||
if ($wantsStream) {
|
||||
$this->streamJsonRpcResponses($result ? [$result] : []);
|
||||
@@ -102,14 +111,9 @@ class Server {
|
||||
// Legacy SSE transport — GET /sse
|
||||
// -------------------------------------------------------------------------
|
||||
public function handleSseGet(\WP_REST_Request $request): void {
|
||||
$auth = \AskTamMCP\Core\Auth::validate($request, \AskTamMCP\Core\Auth::SCOPE_READ);
|
||||
if (is_wp_error($auth)) {
|
||||
status_header($auth->get_error_data()['status'] ?? 401);
|
||||
header('Content-Type: application/json');
|
||||
echo wp_json_encode(['error' => $auth->get_error_message()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@ini_set('max_execution_time', 0);
|
||||
while (ob_get_level()) ob_end_clean();
|
||||
@@ -150,11 +154,8 @@ class Server {
|
||||
// Legacy message handler — POST /message
|
||||
// -------------------------------------------------------------------------
|
||||
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();
|
||||
$result = $this->dispatchJsonRpc($body ?? [], $auth);
|
||||
$result = $this->dispatchWithAuth($body ?? [], $request);
|
||||
|
||||
return new \WP_REST_Response(
|
||||
$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 {
|
||||
if (empty($body['method'])) return null;
|
||||
private function dispatchWithAuth(array $body, \WP_REST_Request $request): ?array {
|
||||
$method = $body['method'] ?? '';
|
||||
$id = $body['id'] ?? null;
|
||||
|
||||
$id = $body['id'] ?? null;
|
||||
$method = $body['method'];
|
||||
if (in_array($method, self::PUBLIC_METHODS, true)) {
|
||||
// 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'] ?? [];
|
||||
|
||||
switch ($method) {
|
||||
@@ -193,6 +215,7 @@ class Server {
|
||||
return $this->rpcResult($id, ['tools' => $this->registry->getToolDefinitions()]);
|
||||
|
||||
case 'tools/call':
|
||||
// authRow is guaranteed non-null here (dispatchWithAuth enforces it)
|
||||
return $this->handleToolCall($id, $params, $authRow);
|
||||
|
||||
case 'notifications/initialized':
|
||||
@@ -238,8 +261,8 @@ class Server {
|
||||
flush();
|
||||
}
|
||||
|
||||
private function handleToolCall(mixed $id, array $params, array $authRow): array {
|
||||
$toolName = $params['name'] ?? '';
|
||||
private function handleToolCall(mixed $id, array $params, ?array $authRow): array {
|
||||
$toolName = $params['name'] ?? '';
|
||||
$arguments = $params['arguments'] ?? [];
|
||||
|
||||
if (empty($toolName)) return $this->rpcError($id, -32602, 'Missing tool name');
|
||||
@@ -250,7 +273,7 @@ class Server {
|
||||
$tool = $this->registry->getTool($toolName);
|
||||
$required = $tool['scope'] ?? '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)) {
|
||||
return $this->rpcError($id, -32000, "Insufficient scope. Required: {$required}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user