diff --git a/.changeset/sep-2164-resource-not-found-invalid-params.md b/.changeset/sep-2164-resource-not-found-invalid-params.md new file mode 100644 index 000000000..af7473d1c --- /dev/null +++ b/.changeset/sep-2164-resource-not-found-invalid-params.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/node': patch +--- + +Resource not found now returns `-32602` (Invalid params) per SEP-2164; `-32002` (`ProtocolErrorCode.ResourceNotFound`) is deprecated. The error includes the requested URI in `data.uri` so clients can still distinguish not-found from other invalid-params errors. Clients SHOULD +continue to accept legacy `-32002` from older servers. + +This supersedes the earlier 2.0.0-alpha.1 / #1389 resource error-code change that moved unknown resource reads to `-32002`. + +`NodeStreamableHTTPServerTransport` now forwards server-configured supported protocol versions to the underlying web-standard transport, so custom version lists passed to `McpServer` are also honored by HTTP header validation. diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a8..196a36750 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/core-internal/src/types/enums.ts b/packages/core-internal/src/types/enums.ts index 0e3b65f9f..49ee6a62a 100644 --- a/packages/core-internal/src/types/enums.ts +++ b/packages/core-internal/src/types/enums.ts @@ -11,6 +11,13 @@ export enum ProtocolErrorCode { InternalError = -32_603, // MCP-specific error codes + /** + * Legacy error code for reads of nonexistent resources. + * + * @deprecated Per SEP-2164, servers MUST return {@link ProtocolErrorCode.InvalidParams} + * (`-32602`, with the requested URI in `data.uri`) for nonexistent resources. This code + * remains exported because clients SHOULD still accept `-32002` from older servers. + */ ResourceNotFound = -32_002, /** * Processing the request requires a capability the client did not declare diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index c101fcd0a..6ceac2211 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -158,6 +158,14 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.send(message, options); } + /** + * Sets the supported protocol versions for header validation. + * Called by the server during connect() to pass its supported versions. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._webStandardTransport.setSupportedProtocolVersions(versions); + } + /** * Handles an incoming HTTP request, whether `GET` or `POST`. * diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 140717c6b..fa384ec77 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -40,6 +40,7 @@ async function getFreePort() { interface TestServerConfig { sessionIdGenerator: (() => string) | undefined; enableJsonResponse?: boolean; + supportedProtocolVersions?: string[]; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; @@ -159,7 +160,13 @@ describe('Zod v4', () => { baseUrl: URL; }> { config ??= { sessionIdGenerator: () => randomUUID() }; - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { logging: {} }, + ...(config.supportedProtocolVersions ? { supportedProtocolVersions: config.supportedProtocolVersions } : {}) + } + ); mcpServer.registerTool( 'greet', @@ -1001,6 +1008,50 @@ describe('Zod v4', () => { expect(response.status).toBe(200); }); + it('should accept protocol versions configured on the connected server', async () => { + const customVersion = '2026-07-28'; + const { + server: customServer, + transport: customTransport, + baseUrl: customBaseUrl + } = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + supportedProtocolVersions: [customVersion, '2025-11-25'] + }); + + try { + const initResponse = await sendPostRequest(customBaseUrl, { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: customVersion, + capabilities: {} + }, + id: 'init-custom-version' + } as JSONRPCMessage); + + expect(initResponse.status).toBe(200); + const customSessionId = initResponse.headers.get('mcp-session-id'); + expect(customSessionId).toBeDefined(); + + const response = await fetch(customBaseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': customSessionId!, + 'mcp-protocol-version': customVersion + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(200); + } finally { + await stopTestServer({ server: customServer, transport: customTransport }); + } + }); + it('should reject unsupported protocol version on GET requests', async () => { sessionId = await initializeServer(); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index be18a7c7c..b8cf9356f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -407,7 +407,14 @@ export class McpServer { }); this.server.setRequestHandler('resources/read', async (request, ctx) => { - const uri = new URL(request.params.uri); + let uri: URL; + try { + uri = new URL(request.params.uri); + } catch { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource URI ${request.params.uri} is invalid`, { + uri: request.params.uri + }); + } // First check for exact resource match const resource = this._registeredResources[uri.toString()]; @@ -426,7 +433,9 @@ export class McpServer { } } - throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); + // SEP-2164: nonexistent resources MUST return -32602 (Invalid params); the + // requested URI is included in `data` so clients can distinguish not-found. + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} not found`, { uri: request.params.uri }); }); this._resourceHandlersInitialized = true; diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d..5abac5a5e 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -85,7 +85,9 @@ server: - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. - # SEP-2164: server returns -32002 without the requested URI in error.data. + # SEP-2164: the draft conformance runner exercises resources/read through the + # 2026-07-28 stateless HTTP path; this fixture is still stateful-only, so the + # request does not reach McpServer's resource-not-found handler yet. - sep-2164-resource-not-found # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore # unrecognized inputResponses keys). diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 387054f0b..b3eb3418e 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer, ResourceTemplate, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -63,6 +63,7 @@ const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; +const DRAFT_PROTOCOL_VERSION = '2026-07-28'; // Function to create a new MCP server instance (one per session) function createMcpServer() { @@ -72,6 +73,7 @@ function createMcpServer() { version: '1.0.0' }, { + supportedProtocolVersions: [...new Set([DRAFT_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS])], capabilities: { tools: { listChanged: true diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a33886429..06bb7b98f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -531,7 +531,8 @@ export const REQUIREMENTS: Record = { }, 'resources:read:unknown-uri': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#error-handling', - behavior: 'resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).' + behavior: 'resources/read for an unknown URI returns JSON-RPC error -32602 (Invalid params) with the URI in error data.', + note: 'SEP-2164: the draft spec upgrades this to MUST -32602 (2025-11-25 said SHOULD -32002); clients SHOULD still accept legacy -32002 from older servers.' }, 'resources:subscribe:capability-required': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', diff --git a/test/e2e/scenarios/resources.test.ts b/test/e2e/scenarios/resources.test.ts index 826066cdf..7fec0f16d 100644 --- a/test/e2e/scenarios/resources.test.ts +++ b/test/e2e/scenarios/resources.test.ts @@ -203,8 +203,10 @@ verifies('resources:read:unknown-uri', async ({ transport }: TestArgs) => { await using _ = await wire(transport, makeServer, client); await expect(client.readResource({ uri: 'file:///no-such-resource' })).rejects.toMatchObject({ - code: -32_002, - message: expect.stringMatching(/not found|unknown/i) + // SEP-2164: nonexistent resources return -32602 (Invalid params) with the URI in `data` + code: -32_602, + message: expect.stringMatching(/not found|unknown/i), + data: { uri: 'file:///no-such-resource' } }); }); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 7bc22f265..96a161c8b 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -2734,8 +2734,84 @@ describe('Zod v4', () => { } }) ).rejects.toMatchObject({ - code: ProtocolErrorCode.ResourceNotFound, - message: expect.stringContaining('not found') + // SEP-2164: nonexistent resources return -32602 (Invalid params) with the URI in `data` + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('not found'), + data: { uri: 'test://nonexistent' } + }); + }); + + test('should echo the exact requested URI for nonexistent resources', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + const requestedUri = 'HTTP://example.com:80/docs/../missing file.txt'; + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'resources/read', + params: { + uri: requestedUri + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('not found'), + data: { uri: requestedUri } + }); + }); + + test('should return invalid params for syntactically invalid resource URIs', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + const requestedUri = 'not a valid URI'; + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'resources/read', + params: { + uri: requestedUri + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('invalid'), + data: { uri: requestedUri } }); });