diff --git a/AGENTS.md b/AGENTS.md index 8353268..d531ae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,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. This server publishes no change notifications, so both tool modes advertise `tools.listChanged: false`. For `subscriptions/listen`, the handler lets the SDK send the acknowledgment with an empty honored filter and then closes the per-request handler. That ends the subscription gracefully with a `complete` result rather than an error, and no SSE stream stays open. +- No MCP session ID, protocol transport state, replay store, Durable Object, or Node async-context bridge is used. This server publishes no change notifications, so both tool modes advertise `tools.listChanged: false`. For `subscriptions/listen`, the SDK acknowledges with an empty honored filter and, since it honours none of the requested types, ends the stream straight away with a `complete` result (SDK 2.2.0+). No SSE stream stays open. - 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/package-lock.json b/package-lock.json index 219fd94..9406f1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,14 @@ "name": "cloudflare-mcp", "version": "0.1.0", "dependencies": { - "@cloudflare/workers-oauth-provider": "^1.2.1", - "@modelcontextprotocol/server": "2.0.0", + "@cloudflare/workers-oauth-provider": "^1.2.2", + "@modelcontextprotocol/server": "2.3.1", "hono": "^4.13.5", "zod": "^4.3.5" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.18", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.3.1", "@types/node": "^25.0.6", "msw": "^2.14.6", "oxfmt": "^0.31.0", @@ -169,9 +169,9 @@ } }, "node_modules/@cloudflare/workers-oauth-provider": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-1.2.1.tgz", - "integrity": "sha512-5bw7JJI9Nd4ArHfNnvTF8H2OpSbA2GbqTmX80ly47h7dR+atgjFsn9Wfz+biKlAKClTEUYE0KAr791RkED41VA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-1.2.2.tgz", + "integrity": "sha512-xroOs4RG/WIV5arUWUvtWno1gP1sUYFqQuZ8xnkPW6VbUrgNmu2o9Y4D76t+2d36JO2f4cVy9UC0Uk78N+R8Jw==", "license": "MIT" }, "node_modules/@cspotcode/source-map-support": { @@ -1294,16 +1294,16 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", + "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" @@ -1313,10 +1313,10 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -1325,12 +1325,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" }, "engines": { @@ -2781,9 +2781,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b011d5d..cb6d1f2 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "seed:prod": "tsx scripts/seed-r2.ts production" }, "dependencies": { - "@cloudflare/workers-oauth-provider": "^1.2.1", - "@modelcontextprotocol/server": "2.0.0", + "@cloudflare/workers-oauth-provider": "^1.2.2", + "@modelcontextprotocol/server": "2.3.1", "hono": "^4.13.5", "zod": "^4.3.5" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.18", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/client": "2.3.1", "@types/node": "^25.0.6", "msw": "^2.14.6", "oxfmt": "^0.31.0", diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts index 82cb76b..5fa0dab 100644 --- a/src/mcp-handler.ts +++ b/src/mcp-handler.ts @@ -44,8 +44,6 @@ function createAuthenticatedHandler(props: AuthProps) { }) } -const SUBSCRIPTIONS_LISTEN = 'subscriptions/listen' - // Handler options are intentionally omitted. The SDK defaults to: // - stateless 2025 compatibility, with a fresh server and no protocol session // - automatic JSON/SSE response shaping (ordinary requests here remain JSON) @@ -109,18 +107,6 @@ export async function handleAuthenticatedMcpRequest( const handler = createAuthenticatedHandler(props) const response = await handler.fetch(request) - // This server publishes no change notifications and keeps no long-lived - // request state. The SDK only serves subscriptions/listen after checking that - // the Mcp-Method header matches the body. It acknowledges the subscription - // with every unsupported notification type left out. Closing this - // per-request handler then ends the subscription gracefully, as the spec - // describes: it writes a `complete` result and closes the stream, so no - // isolate stays pinned. Clients get an empty subscription instead of an - // error. - if (request.headers.get('Mcp-Method') === SUBSCRIPTIONS_LISTEN) { - await handler.close() - } - return withCors(response, request) } diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 34e7279..912b44d 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -111,14 +111,17 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(body.result?.capabilities?.tools?.listChanged).not.toBe(true) }) - it('acknowledges an empty subscription and ends it gracefully', async () => { + // The server declares no list-changed or subscribe capability, so the SDK honours none of + // these and ends the stream itself (SDK 2.2+). An open stream would hang the read below. + it.each([ + { toolsListChanged: true }, + { promptsListChanged: true }, + { resourcesListChanged: true }, + { resourceSubscriptions: ['file:///a'] }, + {} + ])('acknowledges an empty subscription for %j and ends it gracefully', async (notifications) => { const response = await exports.default.fetch( - modernMcpRequest( - API_TOKEN, - 'subscriptions/listen', - { notifications: { toolsListChanged: true } }, - { id: 7 } - ) + modernMcpRequest(API_TOKEN, 'subscriptions/listen', { notifications }, { id: 7 }) ) expect(response.status).toBe(200) @@ -156,7 +159,12 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(response.status).toBe(200) expect(body.result?.resultType).toBe('complete') - expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'whoami']) + expect(body.result?.tools?.map((tool) => tool.name)).toEqual([ + 'docs', + 'search', + 'execute', + 'whoami' + ]) }) it('serves a modern Code Mode tools/call', async () => { @@ -271,7 +279,12 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(codemodeResponse.status).toBe(200) expect(endpointResponse.status).toBe(200) - expect(codemode.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'whoami']) + expect(codemode.result?.tools?.map((tool) => tool.name)).toEqual([ + 'docs', + 'search', + 'execute', + 'whoami' + ]) expect(endpoints.result?.tools?.map((tool) => tool.name)).toEqual([ 'docs', 'whoami', @@ -308,7 +321,12 @@ describe('MCP 2026-07-28 stateless handler', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') expect(response.headers.get('mcp-session-id')).toBeNull() - expect(body.result?.tools?.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'whoami']) + expect(body.result?.tools?.map((tool) => tool.name)).toEqual([ + 'docs', + 'search', + 'execute', + 'whoami' + ]) }) it.each(['GET', 'DELETE'])('rejects session-only %s requests', async (method) => {