diff --git a/AGENTS.md b/AGENTS.md index 1c001ee6..5ea324d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ The core innovation: instead of 2,500 MCP tools (~244K tokens), two tools handle - `src/mcp-handler.ts` uses `createMcpHandler(factory)` directly from `@modelcontextprotocol/server`; this repository does not depend on the Agents SDK. - Each authenticated request creates an upstream handler whose factory closes over validated `AuthProps`, matching the repository's pre-migration explicit data flow. - The handler serves MCP `2026-07-28` and keeps the upstream default stateless 2025 compatibility path. Its factory creates a fresh `McpServer` for every request. -- No MCP session ID, protocol transport state, replay store, Durable Object, or Node async-context bridge is used. The handler sets the SDK's `maxSubscriptions: 0` because this server publishes no change notifications; `subscriptions/listen` is rejected immediately instead of opening a long-lived SSE stream. +- No MCP session ID, protocol transport state, replay store, Durable Object, or Node async-context bridge is used. The handler sets the SDK's `maxSubscriptions: 0` because this server publishes no change notifications; `subscriptions/listen` is rejected immediately instead of opening a long-lived SSE stream. For the same reason, `createServer` declares `tools.listChanged: false` for both tool surfaces before any tool is registered; `registerTool` would otherwise advertise `true`. - Deployment-static Host and browser Origin allowlists cover localhost, staging, and production. Do not derive either trust list from the incoming request URL or headers. ### Worker Loader API diff --git a/src/server.ts b/src/server.ts index 47329b63..c6b1f47d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,12 @@ export async function createServer( const server = new McpServer(SERVER_INFO) const formatResult = truncateToolResult ? truncateResponse : stringifyResponse + // This server never sends notifications/tools/list_changed. Tool lists change + // only on deploy or the daily spec refresh, and mcp-handler.ts rejects + // subscriptions/listen. Declare that before registerTool runs, because it + // otherwise advertises listChanged: true. + server.server.registerCapabilities({ tools: { listChanged: false } }) + if (!codemode) { await registerNonCodemodeTools(server, props, formatResult) return server diff --git a/src/tools/non-codemode.ts b/src/tools/non-codemode.ts index 22de14ba..1dfdb218 100644 --- a/src/tools/non-codemode.ts +++ b/src/tools/non-codemode.ts @@ -22,6 +22,7 @@ import type { AuthProps } from '../auth/types' * schemas per HTTP request. `tools/list` serves the precomputed JSON artifact; * `tools/call` validates and dispatches only the requested operation. * `formatResult` turns each API response body into the tool's text output. + * `server` must already declare the `tools` capability, as `createServer` does. */ export async function registerNonCodemodeTools( server: McpServer, @@ -31,8 +32,6 @@ export async function registerNonCodemodeTools( const tools = await getNonCodemodeTools() const toolsByName = await getNonCodemodeToolMap() - server.server.registerCapabilities({ tools: { listChanged: false } }) - server.server.setRequestHandler('tools/list', () => ({ tools: [DOCS_TOOL, ...tools.map((tool) => toWireTool(toolForAccountAccess(tool)))] })) diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts index b643f7b4..a55915b7 100644 --- a/tests/helpers/mcp.ts +++ b/tests/helpers/mcp.ts @@ -26,6 +26,29 @@ export interface McpToolResult { error?: { code: number; message: string } } +/** Build the legacy JSON-RPC `initialize` request a 2025-era client sends first. */ +export function mcpInitializeRequest(token: string, url = MCP_URL): Request { + return new Request(url, { + method: 'POST', + headers: { + Host: MCP_HOST, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'cloudflare-mcp-tests', version: '1.0.0' } + } + }) + }) +} + /** Build a legacy JSON-RPC `tools/list` request to the worker's `/mcp` endpoint. */ export function mcpToolListRequest(token: string, id = 1): Request { return new Request(MCP_URL, { diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 583af8d8..5291fb9a 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -13,6 +13,7 @@ import { clearKv } from './helpers/kv' import { MCP_HOST, MCP_URL, + mcpInitializeRequest, mcpToolListRequest, modernMcpRequest, parseMcpResult @@ -64,6 +65,21 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(body.result).not.toHaveProperty('serverInfo') }) + it.each([ + ['Code Mode', MCP_URL], + ['non-Code-Mode', `${MCP_URL}?codemode=false`] + ])('advertises no tool list change notifications in %s', async (_surface, url) => { + const discover = await parseMcpResult( + await exports.default.fetch(modernMcpRequest(API_TOKEN, 'server/discover', {}, { url })) + ) + const initialize = await parseMcpResult( + await exports.default.fetch(mcpInitializeRequest(API_TOKEN, url)) + ) + + expect(discover.result).toMatchObject({ capabilities: { tools: { listChanged: false } } }) + expect(initialize.result).toMatchObject({ capabilities: { tools: { listChanged: false } } }) + }) + it('rejects subscriptions instead of opening a long-lived stream', async () => { const response = await exports.default.fetch( modernMcpRequest(API_TOKEN, 'subscriptions/listen', {