From 6dd08ac60804f30fd3c4ff71d60699c1fcbf5f68 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:45:13 +0100 Subject: [PATCH 01/43] ci: trigger workflow on v1.x branch (#1319) --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 60144add1..b92f67d81 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,7 +1,7 @@ on: push: branches: - - main + - v1.x pull_request: workflow_dispatch: release: From a0c9b13484748acab9e5dc8317a7e89c06b52e37 Mon Sep 17 00:00:00 2001 From: Anton Pidkuiko <105124934+antonpk1@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:47:16 +0700 Subject: [PATCH 02/43] fix: README badges links destinations (#907) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e0d3f200f..254671c8f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk) +# MCP TypeScript SDK [![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk)](https://www.npmjs.com/package/@modelcontextprotocol/sdk) [![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk)](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/LICENSE)
Table of Contents From b392f02ffcf37c088dbd114fedf25026ec3913d3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 7 Jan 2026 14:37:11 +0000 Subject: [PATCH 03/43] fix: prevent ReDoS in UriTemplate regex patterns (v1.x backport) (#1365) --- package-lock.json | 4 ++-- package.json | 2 +- src/shared/uriTemplate.ts | 4 ++-- test/shared/uriTemplate.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64bc6f21d..af9692386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.25.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.25.2", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.7", diff --git a/package.json b/package.json index 41d850e15..95a3a5d53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.1", + "version": "1.25.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts index 1dd57f56f..a47a64c97 100644 --- a/src/shared/uriTemplate.ts +++ b/src/shared/uriTemplate.ts @@ -225,7 +225,7 @@ export class UriTemplate { switch (part.operator) { case '': - pattern = part.exploded ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'; + pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'; break; case '+': case '#': @@ -235,7 +235,7 @@ export class UriTemplate { pattern = '\\.([^/,]+)'; break; case '/': - pattern = '/' + (part.exploded ? '([^/]+(?:,[^/]+)*)' : '([^/,]+)'); + pattern = '/' + (part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'); break; default: pattern = '([^/]+)'; diff --git a/test/shared/uriTemplate.test.ts b/test/shared/uriTemplate.test.ts index ec913c0db..5bd54d2cf 100644 --- a/test/shared/uriTemplate.test.ts +++ b/test/shared/uriTemplate.test.ts @@ -284,5 +284,32 @@ describe('UriTemplate', () => { vars[longName] = 'value'; expect(() => template.expand(vars)).not.toThrow(); }); + + it('should not be vulnerable to ReDoS with exploded path patterns', () => { + // Test for ReDoS vulnerability (CVE-2026-0621) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/965 + const template = new UriTemplate('{/id*}'); + const maliciousPayload = '/' + ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); + + it('should not be vulnerable to ReDoS with exploded simple patterns', () => { + // Test for ReDoS vulnerability with simple exploded operator + const template = new UriTemplate('{id*}'); + const maliciousPayload = ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); }); }); From 12ae856cee6ca58499cce24e80f650e78a0c7610 Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Tue, 20 Jan 2026 04:15:11 -0700 Subject: [PATCH 04/43] [v1.x backport] Use correct schema for client sampling validation when tools are present (#1407) Co-authored-by: Claude Opus 4.5 --- src/client/index.ts | 7 ++- test/client/index.test.ts | 126 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 28c0e6253..03a6b40b5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -40,6 +40,7 @@ import { CreateTaskResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, @@ -452,8 +453,10 @@ export class Client< return taskValidationResult.data; } - // For non-task requests, validate against CreateMessageResultSchema - const validationResult = safeParse(CreateMessageResultSchema, result); + // For non-task requests, validate against appropriate schema based on tools presence + const hasTools = params.tools || params.toolChoice; + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const validationResult = safeParse(resultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); diff --git a/test/client/index.test.ts b/test/client/index.test.ts index 9735eb2ba..f5c6a348d 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -4137,3 +4137,129 @@ describe('getSupportedElicitationModes', () => { expect(result.supportsUrlMode).toBe(false); }); }); + +describe('Client sampling validation with tools', () => { + test('should validate array content with tool_use when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use - should validate with CreateMessageResultWithToolsSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: { arg: 'value' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + expect((result.content as Array<{ type: string }>)[0].type).toBe('tool_use'); + }); + + test('should validate single content when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns single content (text) - should still validate with CreateMessageResultWithToolsSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'No tool needed' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should validate single content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns single content - should validate with CreateMessageResultSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should reject array content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns array content - should fail validation with CreateMessageResultSchema + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: [{ type: 'text', text: 'Array response' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }) + ).rejects.toThrow('Invalid sampling result'); + }); + + test('should validate array content when request includes toolChoice', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }], + toolChoice: { mode: 'auto' } + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + }); +}); From 6e8f7e1a43a819ae230373c62b82228dafd892c6 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:17:15 +0100 Subject: [PATCH 05/43] fix: prevent Hono from overriding global Response object (v1.x) (#1411) --- package-lock.json | 18 +++++-- package.json | 2 +- src/server/streamableHttp.ts | 38 +++++++++----- test/server/streamableHttp.test.ts | 83 ++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index af9692386..91dfc79b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.25.2", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -665,9 +665,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1332,6 +1332,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1763,6 +1764,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2305,6 +2307,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2623,6 +2626,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4090,6 +4094,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4170,6 +4175,7 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4215,6 +4221,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4409,6 +4416,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4422,6 +4430,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4574,6 +4583,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 95a3a5d53..9ce780ba7 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bc310d98e..83801fd2c 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -78,14 +78,19 @@ export class StreamableHTTPServerTransport implements Transport { // Create a request listener that wraps the web standard transport // getRequestListener converts Node.js HTTP to Web Standard and properly handles SSE streaming - this._requestListener = getRequestListener(async (webRequest: Request) => { - // Get context if available (set during handleRequest) - const context = this._requestContext.get(webRequest); - return this._webStandardTransport.handleRequest(webRequest, { - authInfo: context?.authInfo, - parsedBody: context?.parsedBody - }); - }); + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + this._requestListener = getRequestListener( + async (webRequest: Request) => { + // Get context if available (set during handleRequest) + const context = this._requestContext.get(webRequest); + return this._webStandardTransport.handleRequest(webRequest, { + authInfo: context?.authInfo, + parsedBody: context?.parsedBody + }); + }, + { overrideGlobalObjects: false } + ); } /** @@ -166,12 +171,17 @@ export class StreamableHTTPServerTransport implements Transport { const authInfo = req.auth; // Create a custom handler that includes our context - const handler = getRequestListener(async (webRequest: Request) => { - return this._webStandardTransport.handleRequest(webRequest, { - authInfo, - parsedBody - }); - }); + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + const handler = getRequestListener( + async (webRequest: Request) => { + return this._webStandardTransport.handleRequest(webRequest, { + authInfo, + parsedBody + }); + }, + { overrideGlobalObjects: false } + ); // Delegate to the request listener which handles all the Node.js <-> Web Standard conversion // including proper SSE streaming support diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 36a12ca9c..ae77dd4e5 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -2910,6 +2910,89 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); +describe('StreamableHTTPServerTransport global Response preservation', () => { + it('should not override the global Response object', () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response (similar to Next.js's NextResponse) + class CustomResponse extends Response { + customProperty = 'test'; + } + + // Verify instanceof works before creating transport + const customResponseBefore = new CustomResponse('test body'); + expect(customResponseBefore instanceof Response).toBe(true); + expect(customResponseBefore instanceof OriginalResponse).toBe(true); + + // Create the transport - this should NOT override globalThis.Response + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Verify the global Response is still the original + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works after creating transport + const customResponseAfter = new CustomResponse('test body'); + expect(customResponseAfter instanceof Response).toBe(true); + expect(customResponseAfter instanceof OriginalResponse).toBe(true); + + // Verify that instances created before transport initialization still work + expect(customResponseBefore instanceof Response).toBe(true); + + // Clean up + transport.close(); + }); + + it('should not override the global Response object when calling handleRequest', async () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response + class CustomResponse extends Response { + customProperty = 'test'; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Create a mock server to test handleRequest + const port = await getFreePort(); + const httpServer = createServer(async (req, res) => { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + }); + + await new Promise(resolve => { + httpServer.listen(port, () => resolve()); + }); + + try { + // Make a request to trigger handleRequest + await fetch(`http://localhost:${port}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Verify the global Response is still the original after handleRequest + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works + const customResponse = new CustomResponse('test body'); + expect(customResponse instanceof Response).toBe(true); + expect(customResponse instanceof OriginalResponse).toBe(true); + } finally { + await transport.close(); + httpServer.close(); + } + }); +}); + /** * Helper to create test server with DNS rebinding protection options */ From 6aba0659654e1ff0699844524595922a61e44cb9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 Jan 2026 11:42:13 +0000 Subject: [PATCH 06/43] chore: bump v1.25.3 for backport fixes (#1412) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91dfc79b4..c774b8883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.2", + "version": "1.25.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.25.2", + "version": "1.25.3", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index 9ce780ba7..8089d8227 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.2", + "version": "1.25.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From aa81a66556fb4434d8a6d1b70f7ac9fc40b5d325 Mon Sep 17 00:00:00 2001 From: Samuele V <4377202+samuv@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:44:06 +0100 Subject: [PATCH 07/43] fix(deps): resolve npm audit vulnerabilities and bump dependencies (v1.x backport) (#1382) Co-authored-by: Konstantin Konstantinov --- package-lock.json | 73 +++++++++++++++++------------ package.json | 12 +++-- test/client/auth-extensions.test.ts | 2 +- 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index c774b8883..c563717c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", @@ -1924,13 +1925,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -2666,10 +2667,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -2886,17 +2890,17 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -3041,9 +3045,9 @@ } }, "node_modules/hono": { - "version": "4.10.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.10.8.tgz", - "integrity": "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", "peer": true, "engines": { @@ -3126,6 +3130,15 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3167,9 +3180,9 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/jose": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.1.tgz", - "integrity": "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3630,9 +3643,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4589,9 +4602,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/package.json b/package.json index 8089d8227..3ff302732 100644 --- a/package.json +++ b/package.json @@ -94,14 +94,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", @@ -141,5 +142,8 @@ }, "resolutions": { "strip-ansi": "6.0.1" + }, + "overrides": { + "qs": "6.14.1" } } diff --git a/test/client/auth-extensions.test.ts b/test/client/auth-extensions.test.ts index a7217307d..9e095202d 100644 --- a/test/client/auth-extensions.test.ts +++ b/test/client/auth-extensions.test.ts @@ -304,7 +304,7 @@ describe('createPrivateKeyJwtAuth', () => { const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( - /Invalid character/ + /Invalid character|cannot be part of a valid base64/ ); }); From 50d9fa3cd12e807e7963bcb9e1548786d3d5d941 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Mon, 2 Feb 2026 16:53:49 +0100 Subject: [PATCH 08/43] Fix #1430: Client Credentials providers scopes support (backported) (#1442) --- src/client/auth-extensions.ts | 24 +++++++- test/client/auth-extensions.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts index f3908d2c2..c90404e94 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -108,6 +108,11 @@ export interface ClientCredentialsProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -140,7 +145,8 @@ export class ClientCredentialsProvider implements OAuthClientProvider { client_name: options.clientName ?? 'client-credentials-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'client_secret_basic' + token_endpoint_auth_method: 'client_secret_basic', + scope: options.scope }; } @@ -216,6 +222,11 @@ export interface PrivateKeyJwtProviderOptions { * Optional JWT lifetime in seconds (default: 300). */ jwtLifetimeSeconds?: number; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -249,7 +260,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; this.addClientAuthentication = createPrivateKeyJwtAuth({ issuer: options.clientId, @@ -324,6 +336,11 @@ export interface StaticPrivateKeyJwtProviderOptions { * Optional client name for metadata. */ clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; } /** @@ -347,7 +364,8 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { client_name: options.clientName ?? 'static-private-key-jwt-client', redirect_uris: [], grant_types: ['client_credentials'], - token_endpoint_auth_method: 'private_key_jwt' + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope }; const assertion = options.jwtBearerAssertion; diff --git a/test/client/auth-extensions.test.ts b/test/client/auth-extensions.test.ts index 9e095202d..623d5e4da 100644 --- a/test/client/auth-extensions.test.ts +++ b/test/client/auth-extensions.test.ts @@ -49,6 +49,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens?.access_token).toBe('test-access-token'); }); + it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client', + scope: 'read write' + }); + + expect(provider.clientMetadata.scope).toBe('read write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('read write'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -92,6 +121,38 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(assertionFromRequest).toBeTruthy(); }); + it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client', + scope: 'openid profile' + }); + + expect(provider.clientMetadata.scope).toBe('openid profile'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', @@ -149,6 +210,39 @@ describe('auth-extensions providers (end-to-end with auth())', () => { expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); }); + + it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client', + scope: 'api:read api:write' + }); + + expect(provider.clientMetadata.scope).toBe('api:read api:write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('api:read api:write'); + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); }); describe('createPrivateKeyJwtAuth', () => { From a05be176cabeae1f933b676e3ce024bf02e2314d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 4 Feb 2026 18:23:03 +0000 Subject: [PATCH 09/43] Merge commit from fork * fix: add transport isolation guards to prevent cross-client data leaks When a single McpServer or stateless transport is reused across multiple client connections, responses can be routed to the wrong client due to message ID collisions. This is a data leak vulnerability (CWE-362). Two guards added: - Protocol.connect() throws if already connected to a transport - Stateless transport.handleRequest() throws if called more than once Also fixes three examples that shared a single McpServer across sessions: - standaloneSseWithGetStreamableHttp.ts - ssePollingExample.ts - elicitationFormExample.ts Related: #820, #204, #243 Co-Authored-By: Claude Opus 4.5 * fix: correct misleading test name and add per-request cleanup - Rename 'should reject second SSE stream even in stateless mode' to 'should allow multiple SSE streams in stateless mode with per-request transports' since the test now asserts both streams succeed (status 200) - Add mcpServer.close() after per-request handling in stateManagementStreamableHttp.test.ts to prevent resource leaks, matching the pattern used in streamableHttp.test.ts - Remove unused mcpServers array that collected but never cleaned up per-request server instances * thread abort through sendRequest and notification to prevent crosstalk * test: remove dummy objects from stateless setupServer return In stateless mode, setupServer() was creating unused McpServer and StreamableHTTPServerTransport instances solely to satisfy the return type. Make mcpServer/serverTransport optional in the return type and simplify the stateless beforeEach/afterEach to only track server and baseUrl. * fix: use McpError for sendRequest abort guard and fix Hono example reuse - sendRequest abort guard now throws McpError(ErrorCode.ConnectionClosed) instead of plain Error for consistency with the rest of the codebase - Hono example updated to create fresh transport and server per request, fixing breakage from the stateless transport reuse guard * fix: use separate resolvers per protocol in transport isolation test Address review feedback: each protocol handler now has its own resolver returning distinct data (responseForA vs responseForB), so the test properly demonstrates that responses route to the correct transport. Uses explicit 'handler entered' promises for deterministic synchronization. --------- Co-authored-by: Claude Opus 4.5 --- src/examples/server/elicitationFormExample.ts | 545 +++++++++--------- .../server/honoWebStandardStreamableHttp.ts | 66 ++- src/examples/server/ssePollingExample.ts | 166 +++--- .../standaloneSseWithGetStreamableHttp.ts | 70 ++- src/server/webStandardStreamableHttp.ts | 8 + src/shared/protocol.ts | 16 + .../stateManagementStreamableHttp.test.ts | 72 ++- test/server/streamableHttp.test.ts | 92 ++- .../protocol-transport-handling.test.ts | 202 +++++-- 9 files changed, 724 insertions(+), 513 deletions(-) diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index 6c0800949..d220806d3 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -14,308 +14,314 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest } from '../../types.js'; import { createMcpExpressApp } from '../../server/express.js'; -// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults -// The validator supports format validation (email, date, etc.) if ajv-formats is installed -const mcpServer = new McpServer( - { - name: 'form-elicitation-example-server', - version: '1.0.0' - }, - { - capabilities: {} - } -); - -/** - * Example 1: Simple user registration tool - * Collects username, email, and password from the user - */ -mcpServer.registerTool( - 'register_user', - { - description: 'Register a new user account by collecting their information', - inputSchema: {} - }, - async () => { - try { - // Request user information through form elicitation - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your registration information:', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your desired username (3-20 characters)', - minLength: 3, - maxLength: 20 - }, - email: { - type: 'string', - title: 'Email', - description: 'Your email address', - format: 'email' - }, - password: { - type: 'string', - title: 'Password', - description: 'Your password (min 8 characters)', - minLength: 8 +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults + // The validator supports format validation (email, date, etc.) if ajv-formats is installed + const mcpServer = new McpServer( + { + name: 'form-elicitation-example-server', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + /** + * Example 1: Simple user registration tool + * Collects username, email, and password from the user + */ + mcpServer.registerTool( + 'register_user', + { + description: 'Register a new user account by collecting their information', + inputSchema: {} + }, + async () => { + try { + // Request user information through form elicitation + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your desired username (3-20 characters)', + minLength: 3, + maxLength: 20 + }, + email: { + type: 'string', + title: 'Email', + description: 'Your email address', + format: 'email' + }, + password: { + type: 'string', + title: 'Password', + description: 'Your password (min 8 characters)', + minLength: 8 + }, + newsletter: { + type: 'boolean', + title: 'Newsletter', + description: 'Subscribe to newsletter?', + default: false + } }, - newsletter: { - type: 'boolean', - title: 'Newsletter', - description: 'Subscribe to newsletter?', - default: false - } - }, - required: ['username', 'email', 'password'] - } - }); - - // Handle the different possible actions - if (result.action === 'accept' && result.content) { - const { username, email, newsletter } = result.content as { - username: string; - email: string; - password: string; - newsletter?: boolean; - }; + required: ['username', 'email', 'password'] + } + }); + // Handle the different possible actions + if (result.action === 'accept' && result.content) { + const { username, email, newsletter } = result.content as { + username: string; + email: string; + password: string; + newsletter?: boolean; + }; + + return { + content: [ + { + type: 'text', + text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: 'Registration cancelled by user.' + } + ] + }; + } else { + return { + content: [ + { + type: 'text', + text: 'Registration was cancelled.' + } + ] + }; + } + } catch (error) { return { content: [ { type: 'text', - text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` + text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` } - ] + ], + isError: true + }; + } + } + ); + + /** + * Example 2: Multi-step workflow with multiple form elicitation requests + * Demonstrates how to collect information in multiple steps + */ + mcpServer.registerTool( + 'create_event', + { + description: 'Create a calendar event by collecting event details', + inputSchema: {} + }, + async () => { + try { + // Step 1: Collect basic event information + const basicInfo = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 1: Enter basic event information', + requestedSchema: { + type: 'object', + properties: { + title: { + type: 'string', + title: 'Event Title', + description: 'Name of the event', + minLength: 1 + }, + description: { + type: 'string', + title: 'Description', + description: 'Event description (optional)' + } + }, + required: ['title'] + } + }); + + if (basicInfo.action !== 'accept' || !basicInfo.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Step 2: Collect date and time + const dateTime = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 2: Enter date and time', + requestedSchema: { + type: 'object', + properties: { + date: { + type: 'string', + title: 'Date', + description: 'Event date', + format: 'date' + }, + startTime: { + type: 'string', + title: 'Start Time', + description: 'Event start time (HH:MM)' + }, + duration: { + type: 'integer', + title: 'Duration', + description: 'Duration in minutes', + minimum: 15, + maximum: 480 + } + }, + required: ['date', 'startTime', 'duration'] + } + }); + + if (dateTime.action !== 'accept' || !dateTime.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Combine all collected information + const event = { + ...basicInfo.content, + ...dateTime.content }; - } else if (result.action === 'decline') { + return { content: [ { type: 'text', - text: 'Registration cancelled by user.' + text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` } ] }; - } else { + } catch (error) { return { content: [ { type: 'text', - text: 'Registration was cancelled.' + text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` } - ] + ], + isError: true }; } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; } - } -); - -/** - * Example 2: Multi-step workflow with multiple form elicitation requests - * Demonstrates how to collect information in multiple steps - */ -mcpServer.registerTool( - 'create_event', - { - description: 'Create a calendar event by collecting event details', - inputSchema: {} - }, - async () => { - try { - // Step 1: Collect basic event information - const basicInfo = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 1: Enter basic event information', - requestedSchema: { - type: 'object', - properties: { - title: { - type: 'string', - title: 'Event Title', - description: 'Name of the event', - minLength: 1 - }, - description: { - type: 'string', - title: 'Description', - description: 'Event description (optional)' - } - }, - required: ['title'] - } - }); - - if (basicInfo.action !== 'accept' || !basicInfo.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Step 2: Collect date and time - const dateTime = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Step 2: Enter date and time', - requestedSchema: { - type: 'object', - properties: { - date: { - type: 'string', - title: 'Date', - description: 'Event date', - format: 'date' - }, - startTime: { - type: 'string', - title: 'Start Time', - description: 'Event start time (HH:MM)' + ); + + /** + * Example 3: Collecting address information + * Demonstrates validation with patterns and optional fields + */ + mcpServer.registerTool( + 'update_shipping_address', + { + description: 'Update shipping address with validation', + inputSchema: {} + }, + async () => { + try { + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your shipping address:', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Recipient name', + minLength: 1 + }, + street: { + type: 'string', + title: 'Street Address', + minLength: 1 + }, + city: { + type: 'string', + title: 'City', + minLength: 1 + }, + state: { + type: 'string', + title: 'State/Province', + minLength: 2, + maxLength: 2 + }, + zipCode: { + type: 'string', + title: 'ZIP/Postal Code', + description: '5-digit ZIP code' + }, + phone: { + type: 'string', + title: 'Phone Number (optional)', + description: 'Contact phone number' + } }, - duration: { - type: 'integer', - title: 'Duration', - description: 'Duration in minutes', - minimum: 15, - maximum: 480 - } - }, - required: ['date', 'startTime', 'duration'] - } - }); - - if (dateTime.action !== 'accept' || !dateTime.content) { - return { - content: [{ type: 'text', text: 'Event creation cancelled.' }] - }; - } - - // Combine all collected information - const event = { - ...basicInfo.content, - ...dateTime.content - }; - - return { - content: [ - { - type: 'text', - text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` - } - ] - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` + required: ['name', 'street', 'city', 'state', 'zipCode'] } - ], - isError: true - }; - } - } -); - -/** - * Example 3: Collecting address information - * Demonstrates validation with patterns and optional fields - */ -mcpServer.registerTool( - 'update_shipping_address', - { - description: 'Update shipping address with validation', - inputSchema: {} - }, - async () => { - try { - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: 'Please provide your shipping address:', - requestedSchema: { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Full Name', - description: 'Recipient name', - minLength: 1 - }, - street: { - type: 'string', - title: 'Street Address', - minLength: 1 - }, - city: { - type: 'string', - title: 'City', - minLength: 1 - }, - state: { - type: 'string', - title: 'State/Province', - minLength: 2, - maxLength: 2 - }, - zipCode: { - type: 'string', - title: 'ZIP/Postal Code', - description: '5-digit ZIP code' - }, - phone: { - type: 'string', - title: 'Phone Number (optional)', - description: 'Contact phone number' - } - }, - required: ['name', 'street', 'city', 'state', 'zipCode'] - } - }); + }); - if (result.action === 'accept' && result.content) { + if (result.action === 'accept' && result.content) { + return { + content: [ + { + type: 'text', + text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [{ type: 'text', text: 'Address update cancelled by user.' }] + }; + } else { + return { + content: [{ type: 'text', text: 'Address update was cancelled.' }] + }; + } + } catch (error) { return { content: [ { type: 'text', - text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` + text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` } - ] - }; - } else if (result.action === 'decline') { - return { - content: [{ type: 'text', text: 'Address update cancelled by user.' }] - }; - } else { - return { - content: [{ type: 'text', text: 'Address update was cancelled.' }] + ], + isError: true }; } - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; } - } -); + ); + + return mcpServer; +}; async function main() { const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; @@ -357,7 +363,8 @@ async function main() { } }; - // Connect the transport to the MCP server BEFORE handling the request + // Create a new server per session and connect it to the transport + const mcpServer = getServer(); await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); diff --git a/src/examples/server/honoWebStandardStreamableHttp.ts b/src/examples/server/honoWebStandardStreamableHttp.ts index ba8805eae..5d4510f46 100644 --- a/src/examples/server/honoWebStandardStreamableHttp.ts +++ b/src/examples/server/honoWebStandardStreamableHttp.ts @@ -15,29 +15,30 @@ import { McpServer } from '../../server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '../../server/webStandardStreamableHttp.js'; import { CallToolResult } from '../../types.js'; -// Create the MCP server -const server = new McpServer({ - name: 'hono-webstandard-mcp-server', - version: '1.0.0' -}); +// Factory function to create a new MCP server per request (stateless mode) +const getServer = () => { + const server = new McpServer({ + name: 'hono-webstandard-mcp-server', + version: '1.0.0' + }); -// Register a simple greeting tool -server.registerTool( - 'greet', - { - title: 'Greeting Tool', - description: 'A simple greeting tool', - inputSchema: { name: z.string().describe('Name to greet') } - }, - async ({ name }): Promise => { - return { - content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] - }; - } -); + // Register a simple greeting tool + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'A simple greeting tool', + inputSchema: { name: z.string().describe('Name to greet') } + }, + async ({ name }): Promise => { + return { + content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] + }; + } + ); -// Create a stateless transport (no options = no session management) -const transport = new WebStandardStreamableHTTPServerTransport(); + return server; +}; // Create the Hono app const app = new Hono(); @@ -56,19 +57,22 @@ app.use( // Health check endpoint app.get('/health', c => c.json({ status: 'ok' })); -// MCP endpoint -app.all('/mcp', c => transport.handleRequest(c.req.raw)); +// MCP endpoint - create a fresh transport and server per request (stateless) +app.all('/mcp', async (c) => { + const transport = new WebStandardStreamableHTTPServerTransport(); + const server = getServer(); + await server.connect(transport); + return transport.handleRequest(c.req.raw); +}); // Start the server const PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; -server.connect(transport).then(() => { - console.log(`Starting Hono MCP server on port ${PORT}`); - console.log(`Health check: http://localhost:${PORT}/health`); - console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); +console.log(`Starting Hono MCP server on port ${PORT}`); +console.log(`Health check: http://localhost:${PORT}/health`); +console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); - serve({ - fetch: app.fetch, - port: PORT - }); +serve({ + fetch: app.fetch, + port: PORT }); diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts index bbecf2fdb..04d9f7751 100644 --- a/src/examples/server/ssePollingExample.ts +++ b/src/examples/server/ssePollingExample.ts @@ -12,7 +12,7 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import { Request, Response } from 'express'; +import { type Request, type Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { createMcpExpressApp } from '../../server/express.js'; @@ -21,87 +21,92 @@ import { CallToolResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import cors from 'cors'; -// Create the MCP server -const server = new McpServer( - { - name: 'sse-polling-example', - version: '1.0.0' - }, - { - capabilities: { logging: {} } - } -); - -// Register a long-running tool that demonstrates server-initiated disconnect -server.tool( - 'long-task', - 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', - {}, - async (_args, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - console.log(`[${extra.sessionId}] Starting long-task...`); - - // Send first progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 25% - Starting work...' - }, - extra.sessionId - ); - await sleep(1000); - - // Send second progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 50% - Halfway there...' - }, - extra.sessionId - ); - await sleep(1000); - - // Server decides to disconnect the client to free resources - // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval - // Use extra.closeSSEStream callback - available when eventStore is configured - if (extra.closeSSEStream) { - console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); - extra.closeSSEStream(); +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + const server = new McpServer( + { + name: 'sse-polling-example', + version: '1.0.0' + }, + { + capabilities: { logging: {} } } + ); + + // Register a long-running tool that demonstrates server-initiated disconnect + server.tool( + 'long-task', + 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting long-task...`); - // Continue processing while client is disconnected - // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 75% - Almost done (sent while client disconnected)...' - }, - extra.sessionId - ); - - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 100% - Complete!' - }, - extra.sessionId - ); - - console.log(`[${extra.sessionId}] Task complete`); - - return { - content: [ + // Send first progress notification + await server.sendLoggingMessage( { - type: 'text', - text: 'Long task completed successfully!' - } - ] - }; - } -); + level: 'info', + data: 'Progress: 25% - Starting work...' + }, + extra.sessionId + ); + await sleep(1000); + + // Send second progress notification + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 50% - Halfway there...' + }, + extra.sessionId + ); + await sleep(1000); + + // Server decides to disconnect the client to free resources + // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval + // Use extra.closeSSEStream callback - available when eventStore is configured + if (extra.closeSSEStream) { + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + extra.closeSSEStream(); + } + + // Continue processing while client is disconnected + // Events are stored in eventStore and will be replayed on reconnect + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 75% - Almost done (sent while client disconnected)...' + }, + extra.sessionId + ); + + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 100% - Complete!' + }, + extra.sessionId + ); + + console.log(`[${extra.sessionId}] Task complete`); + + return { + content: [ + { + type: 'text', + text: 'Long task completed successfully!' + } + ] + }; + } + ); + + return server; +}; // Set up Express app const app = createMcpExpressApp(); @@ -131,7 +136,8 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); - // Connect the MCP server to the transport + // Create a new server per session and connect it to the transport + const server = getServer(); await server.connect(transport); } diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 225ef1f34..97882874d 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -5,35 +5,46 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest, ReadResourceResult } from '../../types.js'; import { createMcpExpressApp } from '../../server/express.js'; -// Create an MCP server with implementation details -const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' -}); +// Factory to create a new MCP server per session. +// Each session needs its own server+transport pair to avoid cross-session contamination. +const getServer = () => { + const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0' + }); -// Store transports by session ID to send notifications -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + const addResource = (name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.registerResource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }] + }; + } + ); + }; -const addResource = (name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.registerResource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; - } - ); -}; + addResource('example-resource', 'Initial content for example-resource'); -addResource('example-resource', 'Initial content for example-resource'); + // Periodically add new resources to demonstrate notifications + const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + addResource(name, `Content for ${name}`); + }, 5000); -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - addResource(name, `Content for ${name}`); -}, 5000); // Change resources every 5 seconds for testing + // Clean up the interval when the server closes + server.server.onclose = () => { + clearInterval(resourceChangeInterval); + }; + + return server; +}; + +// Store transports by session ID to send notifications +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const app = createMcpExpressApp(); @@ -59,7 +70,8 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); - // Connect the transport to the MCP server + // Create a new server per session and connect it to the transport + const server = getServer(); await server.connect(transport); // Handle the request - the onsessioninitialized callback will store the transport @@ -121,7 +133,9 @@ app.listen(PORT, error => { // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - await server.close(); + for (const sessionId in transports) { + await transports[sessionId].close(); + delete transports[sessionId]; + } process.exit(0); }); diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 3ae9846c2..c811c54c4 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -210,6 +210,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private sessionIdGenerator: (() => string) | undefined; private _started: boolean = false; + private _hasHandledRequest: boolean = false; private _streamMapping: Map = new Map(); private _requestToStreamMapping: Map = new Map(); private _requestResponseMap: Map = new Map(); @@ -319,6 +320,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Returns a Response object (Web Standard) */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { + // In stateless mode (no sessionIdGenerator), each request must use a fresh transport. + // Reusing a stateless transport causes message ID collisions between clients. + if (!this.sessionIdGenerator && this._hasHandledRequest) { + throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.'); + } + this._hasHandledRequest = true; + // Validate request headers for DNS rebinding protection const validationError = this.validateRequestHeaders(req); if (validationError) { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index aa242a647..5bb6d62ed 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -605,6 +605,12 @@ export abstract class Protocol { + if (this._transport) { + throw new Error( + 'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.' + ); + } + this._transport = transport; const _onclose = this.transport?.onclose; this._transport.onclose = () => { @@ -642,6 +648,12 @@ export abstract class Protocol { + if (abortController.signal.aborted) return; // Include related-task metadata if this request is part of a task const notificationOptions: NotificationOptions = { relatedRequestId: request.id }; if (relatedTaskId) { @@ -727,6 +740,9 @@ export abstract class Protocol { + if (abortController.signal.aborted) { + throw new McpError(ErrorCode.ConnectionClosed, 'Request was cancelled'); + } // Include related-task metadata if this request is part of a task const requestOptions: RequestOptions = { ...options, relatedRequestId: request.id }; if (relatedTaskId && !requestOptions.relatedTask) { diff --git a/test/integration-tests/stateManagementStreamableHttp.test.ts b/test/integration-tests/stateManagementStreamableHttp.test.ts index d79d95c75..311aa23c0 100644 --- a/test/integration-tests/stateManagementStreamableHttp.test.ts +++ b/test/integration-tests/stateManagementStreamableHttp.test.ts @@ -17,9 +17,11 @@ import { listenOnRandomPort } from '../helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; describe('Streamable HTTP Transport Session Management', () => { - // Function to set up the server with optional session management - async function setupServer(withSessionManagement: boolean) { - const server: Server = createServer(); + /** + * Helper to create and configure a fresh McpServer instance with standard + * resources, prompts, and tools for testing. + */ + function createMcpServer(): McpServer { const mcpServer = new McpServer( { name: 'test-server', version: '1.0.0' }, { @@ -67,43 +69,67 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } ); - // Create transport with or without session management - const serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: withSessionManagement - ? () => randomUUID() // With session management, generate UUID - : undefined // Without session management, return undefined - }); + return mcpServer; + } + + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean): Promise<{ + server: Server; + mcpServer?: McpServer; + serverTransport?: StreamableHTTPServerTransport; + baseUrl: URL; + }> { + const server: Server = createServer(); - await mcpServer.connect(serverTransport); + if (withSessionManagement) { + // Stateful mode: single transport + server for the session + const mcpServer = createMcpServer(); + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); - server.on('request', async (req, res) => { - await serverTransport.handleRequest(req, res); - }); + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await listenOnRandomPort(server); + + return { server, mcpServer, serverTransport, baseUrl }; + } else { + // Stateless mode: create a fresh transport + server per request + // to comply with the guard that stateless transports cannot be reused. + server.on('request', async (req, res) => { + const mcpServer = createMcpServer(); + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await mcpServer.connect(serverTransport); + await serverTransport.handleRequest(req, res); + // Close the per-request mcpServer after handling to avoid leaks + await mcpServer.close(); + }); - // Start the server on a random port - const baseUrl = await listenOnRandomPort(server); + // Start the server on a random port + const baseUrl = await listenOnRandomPort(server); - return { server, mcpServer, serverTransport, baseUrl }; + return { server, baseUrl }; + } } describe('Stateless Mode', () => { let server: Server; - let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; let baseUrl: URL; beforeEach(async () => { const setup = await setupServer(false); server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; baseUrl = setup.baseUrl; }); afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); server.close(); }); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index ae77dd4e5..1c4d5ed84 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -1529,20 +1529,56 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Test stateless mode describe('StreamableHTTPServerTransport in stateless mode', () => { let server: Server; - let transport: StreamableHTTPServerTransport; let baseUrl: URL; + // In stateless mode, each request must use a fresh transport + server pair. + // The HTTP server creates these per-request and delegates accordingly. beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + server = createServer(async (req, res) => { + try { + const { transport, mcpServer } = await createStatelessHandler(); + await transport.handleRequest(req, res); + // Close the per-request mcpServer after handling to avoid leaks + await mcpServer.close(); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + baseUrl = await listenOnRandomPort(server); }); afterEach(async () => { - await stopTestServer({ server, transport }); + server.close(); }); + /** + * Creates a fresh transport + mcpServer pair for a single stateless request. + */ + async function createStatelessHandler(): Promise<{ + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { name: z.string().describe('Name to greet') }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + await mcpServer.connect(transport); + + return { transport, mcpServer }; + } + it('should operate without session ID validation', async () => { // Initialize the server first const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); @@ -1552,6 +1588,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(initResponse.headers.get('mcp-session-id')).toBeNull(); // Try request without session ID - should work in stateless mode + // (a fresh transport is created per request) const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); expect(toolsResponse.status).toBe(200); @@ -1585,14 +1622,14 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(response2.status).toBe(200); }); - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time + it('should allow multiple SSE streams in stateless mode with per-request transports', async () => { + // Each request gets its own transport, so multiple SSE streams can + // coexist since they are handled by separate transport instances // Initialize the server first await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Open first SSE stream + // Open first SSE stream - this uses its own per-request transport const stream1 = await fetch(baseUrl, { method: 'GET', headers: { @@ -1602,7 +1639,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); expect(stream1.status).toBe(200); - // Open second SSE stream - should still be rejected, stateless mode still only allows one + // Open second SSE stream - also gets its own per-request transport, + // so it should also succeed (each transport only handles one request) const stream2 = await fetch(baseUrl, { method: 'GET', headers: { @@ -1610,7 +1648,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'mcp-protocol-version': '2025-11-25' } }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed + // With per-request transports in stateless mode, each GET gets its own + // transport, so the second one also succeeds + expect(stream2.status).toBe(200); }); }); @@ -2868,17 +2908,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { describe('Combined validations', () => { it('should validate both host and origin when both are configured', async () => { - const result = await createTestServerWithDnsProtection({ + // In stateless mode, each request needs a fresh transport, so we + // test invalid and valid origins with separate server instances. + + // Test with invalid origin + const result1 = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3001'], enableDnsRebindingProtection: true }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + server = result1.server; + transport = result1.transport; + baseUrl = result1.baseUrl; - // Test with invalid origin (host will be automatically correct via fetch) const response1 = await fetch(baseUrl, { method: 'POST', headers: { @@ -2893,7 +2936,20 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const body1 = await response1.json(); expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); - // Test with valid origin + // Clean up first server + await stopTestServer({ server, transport }); + + // Test with valid origin using a fresh server+transport + const result2 = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true + }); + server = result2.server; + transport = result2.transport; + baseUrl = result2.baseUrl; + const response2 = await fetch(baseUrl, { method: 'POST', headers: { diff --git a/test/shared/protocol-transport-handling.test.ts b/test/shared/protocol-transport-handling.test.ts index 60eff5c2e..148639460 100644 --- a/test/shared/protocol-transport-handling.test.ts +++ b/test/shared/protocol-transport-handling.test.ts @@ -27,30 +27,36 @@ class MockTransport implements Transport { } } -describe('Protocol transport handling bug', () => { - let protocol: Protocol; +function createProtocol(): Protocol { + return new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); +} + +describe('Protocol transport handling', () => { let transportA: MockTransport; let transportB: MockTransport; beforeEach(() => { - protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - transportA = new MockTransport('A'); transportB = new MockTransport('B'); }); - test('should send response to the correct transport when multiple clients are connected', async () => { - // Set up a request handler that simulates processing time - let resolveHandler: (value: Result) => void; - const handlerPromise = new Promise(resolve => { - resolveHandler = resolve; - }); + test('should send response to the correct transport when using separate protocol instances', async () => { + const protocolA = createProtocol(); + const protocolB = createProtocol(); + + // Each protocol gets its own resolver so we can verify responses route correctly + let resolveA: (value: Result) => void; + let resolveB: (value: Result) => void; + let handlerAEnteredResolve: () => void; + let handlerBEnteredResolve: () => void; + const handlerAEntered = new Promise(resolve => { handlerAEnteredResolve = resolve; }); + const handlerBEntered = new Promise(resolve => { handlerBEnteredResolve = resolve; }); const TestRequestSchema = z.object({ method: z.literal('test/method'), @@ -61,13 +67,22 @@ describe('Protocol transport handling bug', () => { .optional() }); - protocol.setRequestHandler(TestRequestSchema, async request => { - console.log(`Processing request from ${request.params?.from}`); - return handlerPromise; + protocolA.setRequestHandler(TestRequestSchema, async () => { + return new Promise(resolve => { + resolveA = resolve; + handlerAEnteredResolve(); + }); + }); + + protocolB.setRequestHandler(TestRequestSchema, async () => { + return new Promise(resolve => { + resolveB = resolve; + handlerBEnteredResolve(); + }); }); // Client A connects and sends a request - await protocol.connect(transportA); + await protocolA.connect(transportA); const requestFromA = { jsonrpc: '2.0' as const, @@ -79,9 +94,8 @@ describe('Protocol transport handling bug', () => { // Simulate client A sending a request transportA.onmessage?.(requestFromA); - // While A's request is being processed, client B connects - // This overwrites the transport reference in the protocol - await protocol.connect(transportB); + // Client B connects to a separate protocol instance + await protocolB.connect(transportB); const requestFromB = { jsonrpc: '2.0' as const, @@ -93,19 +107,18 @@ describe('Protocol transport handling bug', () => { // Client B sends its own request transportB.onmessage?.(requestFromB); - // Now complete A's request - resolveHandler!({ data: 'responseForA' } as Result); + // Wait for both handlers to be invoked so resolvers are captured + await handlerAEntered; + await handlerBEntered; - // Wait for async operations to complete - await new Promise(resolve => setTimeout(resolve, 10)); + // Resolve each handler with distinct data + resolveA!({ data: 'responseForA' } as Result); + resolveB!({ data: 'responseForB' } as Result); - // Check where the responses went - console.log('Transport A received:', transportA.sentMessages); - console.log('Transport B received:', transportB.sentMessages); - - // FIXED: Each transport now receives its own response + // Wait for response delivery (transport.send is async) + await new Promise(resolve => setTimeout(resolve, 10)); - // Transport A should receive response for request ID 1 + // Each transport receives its own response expect(transportA.sentMessages.length).toBe(1); expect(transportA.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', @@ -113,18 +126,17 @@ describe('Protocol transport handling bug', () => { result: { data: 'responseForA' } }); - // Transport B should only receive its own response (when implemented) expect(transportB.sentMessages.length).toBe(1); expect(transportB.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', id: 2, - result: { data: 'responseForA' } // Same handler result in this test + result: { data: 'responseForB' } }); }); - test('demonstrates the timing issue with multiple rapid connections', async () => { - const delays: number[] = []; - const results: { transport: string; response: JSONRPCMessage[] }[] = []; + test('demonstrates isolation with separate protocol instances for rapid connections', async () => { + const protocolA = createProtocol(); + const protocolB = createProtocol(); const DelayedRequestSchema = z.object({ method: z.literal('test/delayed'), @@ -136,21 +148,20 @@ describe('Protocol transport handling bug', () => { .optional() }); - // Set up handler with variable delay - protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { - const delay = request.params?.delay || 0; - delays.push(delay); - - await new Promise(resolve => setTimeout(resolve, delay)); - - return { - processedBy: `handler-${extra.requestId}`, - delay: delay - } as Result; - }); + // Set up handler with variable delay on each protocol + for (const protocol of [protocolA, protocolB]) { + protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { + const delay = request.params?.delay || 0; + await new Promise(resolve => setTimeout(resolve, delay)); + return { + processedBy: `handler-${extra.requestId}`, + delay: delay + } as Result; + }); + } - // Rapid succession of connections and requests - await protocol.connect(transportA); + // Connect and send requests + await protocolA.connect(transportA); transportA.onmessage?.({ jsonrpc: '2.0' as const, method: 'test/delayed', @@ -160,7 +171,7 @@ describe('Protocol transport handling bug', () => { // Connect B while A is processing setTimeout(async () => { - await protocol.connect(transportB); + await protocolB.connect(transportB); transportB.onmessage?.({ jsonrpc: '2.0' as const, method: 'test/delayed', @@ -172,18 +183,81 @@ describe('Protocol transport handling bug', () => { // Wait for all processing await new Promise(resolve => setTimeout(resolve, 100)); - // Collect results - if (transportA.sentMessages.length > 0) { - results.push({ transport: 'A', response: transportA.sentMessages }); - } - if (transportB.sentMessages.length > 0) { - results.push({ transport: 'B', response: transportB.sentMessages }); - } - - console.log('Timing test results:', results); - - // FIXED: Each transport receives its own responses + // Each transport receives its own responses expect(transportA.sentMessages.length).toBe(1); expect(transportB.sentMessages.length).toBe(1); }); + + test('connect guard throws when calling connect() twice without closing', async () => { + const protocol = createProtocol(); + + await protocol.connect(transportA); + + await expect(protocol.connect(transportB)).rejects.toThrow( + 'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.' + ); + }); + + test('connect succeeds after calling close() first', async () => { + const protocol = createProtocol(); + + await protocol.connect(transportA); + await protocol.close(); + + // Should succeed without error + await expect(protocol.connect(transportB)).resolves.toBeUndefined(); + }); + + test('close() aborts in-flight request handlers', async () => { + const protocol = createProtocol(); + + const SlowRequestSchema = z.object({ + method: z.literal('test/slow') + }); + + let capturedSignal: AbortSignal | undefined; + let capturedSendNotification: ((notification: Notification) => Promise) | undefined; + let resolveHandler: () => void; + const handlerBlocking = new Promise(resolve => { + resolveHandler = resolve; + }); + + protocol.setRequestHandler(SlowRequestSchema, async (_request, extra) => { + capturedSignal = extra.signal; + capturedSendNotification = extra.sendNotification; + // Block the handler until we release it + await handlerBlocking; + return {} as Result; + }); + + await protocol.connect(transportA); + + // Send a request to trigger the handler + transportA.onmessage?.({ + jsonrpc: '2.0' as const, + method: 'test/slow', + id: 1 + }); + + // Wait for the handler to start and capture the signal + await new Promise(resolve => setTimeout(resolve, 10)); + expect(capturedSignal).toBeDefined(); + expect(capturedSignal!.aborted).toBe(false); + + // Close the protocol while the handler is still in-flight + await protocol.close(); + + // The signal should now be aborted + expect(capturedSignal!.aborted).toBe(true); + + // sendNotification should be a no-op after close (no error thrown) + await expect(capturedSendNotification!({ method: 'notifications/test' } as Notification)).resolves.toBeUndefined(); + + // No notification should have been sent to the transport + const notifications = transportA.sentMessages.filter((m: JSONRPCMessage) => 'method' in m && m.method === 'notifications/test'); + expect(notifications).toHaveLength(0); + + // Release the handler so the promise chain completes + resolveHandler!(); + }); }); From 4f01e7e0708e1a85ccc7dbf39e850005f2d9ff03 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 4 Feb 2026 18:52:07 +0000 Subject: [PATCH 10/43] fix: add non-null assertions for optional setupServer fields in stateful test --- test/integration-tests/stateManagementStreamableHttp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration-tests/stateManagementStreamableHttp.test.ts b/test/integration-tests/stateManagementStreamableHttp.test.ts index 311aa23c0..672bfb92f 100644 --- a/test/integration-tests/stateManagementStreamableHttp.test.ts +++ b/test/integration-tests/stateManagementStreamableHttp.test.ts @@ -285,8 +285,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { beforeEach(async () => { const setup = await setupServer(true); server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; + mcpServer = setup.mcpServer!; + serverTransport = setup.serverTransport!; baseUrl = setup.baseUrl; }); From fe9c07b465871394c7069207c86513df9c1194a4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 4 Feb 2026 19:00:12 +0000 Subject: [PATCH 11/43] chore: bump version to 1.26.0 (#1479) --- package-lock.json | 4 ++-- package.json | 2 +- src/examples/server/honoWebStandardStreamableHttp.ts | 2 +- test/shared/protocol-transport-handling.test.ts | 8 ++++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index c563717c0..e3f00b3a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.3", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.25.3", + "version": "1.26.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index 3ff302732..dc02209b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.25.3", + "version": "1.26.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/examples/server/honoWebStandardStreamableHttp.ts b/src/examples/server/honoWebStandardStreamableHttp.ts index 5d4510f46..1a19ee8a4 100644 --- a/src/examples/server/honoWebStandardStreamableHttp.ts +++ b/src/examples/server/honoWebStandardStreamableHttp.ts @@ -58,7 +58,7 @@ app.use( app.get('/health', c => c.json({ status: 'ok' })); // MCP endpoint - create a fresh transport and server per request (stateless) -app.all('/mcp', async (c) => { +app.all('/mcp', async c => { const transport = new WebStandardStreamableHTTPServerTransport(); const server = getServer(); await server.connect(transport); diff --git a/test/shared/protocol-transport-handling.test.ts b/test/shared/protocol-transport-handling.test.ts index 148639460..1e698481f 100644 --- a/test/shared/protocol-transport-handling.test.ts +++ b/test/shared/protocol-transport-handling.test.ts @@ -55,8 +55,12 @@ describe('Protocol transport handling', () => { let resolveB: (value: Result) => void; let handlerAEnteredResolve: () => void; let handlerBEnteredResolve: () => void; - const handlerAEntered = new Promise(resolve => { handlerAEnteredResolve = resolve; }); - const handlerBEntered = new Promise(resolve => { handlerBEnteredResolve = resolve; }); + const handlerAEntered = new Promise(resolve => { + handlerAEnteredResolve = resolve; + }); + const handlerBEntered = new Promise(resolve => { + handlerBEnteredResolve = resolve; + }); const TestRequestSchema = z.object({ method: z.literal('test/method'), From b0cf83716b6bacc575f3ef3ff3403b60ceb017b8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:18:45 +0000 Subject: [PATCH 12/43] feat: add conformance test infrastructure for v1.x (#1518) Co-authored-by: Claude Opus 4.6 Co-authored-by: Paul Carleton --- .github/workflows/conformance.yml | 40 + .gitignore | 1 + package-lock.json | 115 ++- package.json | 8 +- test/conformance/conformance-baseline.yml | 14 + .../scripts/run-server-conformance.sh | 45 + test/conformance/src/everythingClient.ts | 370 +++++++ test/conformance/src/everythingServer.ts | 953 ++++++++++++++++++ .../src/helpers/conformanceOAuthProvider.ts | 87 ++ test/conformance/src/helpers/logger.ts | 27 + .../conformance/src/helpers/withOAuthRetry.ts | 81 ++ test/conformance/tsconfig.json | 8 + 12 files changed, 1734 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/conformance.yml create mode 100644 test/conformance/conformance-baseline.yml create mode 100755 test/conformance/scripts/run-server-conformance.sh create mode 100644 test/conformance/src/everythingClient.ts create mode 100644 test/conformance/src/everythingServer.ts create mode 100644 test/conformance/src/helpers/conformanceOAuthProvider.ts create mode 100644 test/conformance/src/helpers/logger.ts create mode 100644 test/conformance/src/helpers/withOAuthRetry.ts create mode 100644 test/conformance/tsconfig.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..974a84cdb --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,40 @@ +name: Conformance Tests + +on: + push: + branches: [v1.x] + pull_request: + branches: [v1.x] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:client:all + + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:server diff --git a/.gitignore b/.gitignore index a1b83bc4f..81be15073 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ dist/ # IDE .idea/ +test/conformance/node_modules/ diff --git a/package-lock.json b/package-lock.json index e3f00b3a7..cca035cbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.11", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -735,6 +736,67 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/conformance": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.11.tgz", + "integrity": "sha512-cqayAmyTUhnRsyrOuTqZ+kCc2w/goppxnqZ+XrOsVd/M25No/HiZ1GbZI92sFA7ONYzonqRja56G9IiISIns3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", + "express": "^5.1.0", + "jose": "^6.1.2", + "undici": "^7.19.0", + "yaml": "^2.8.2", + "zod": "^3.25.76" + }, + "bin": { + "conformance": "dist/index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1333,7 +1395,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1765,7 +1826,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2007,6 +2067,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -2308,7 +2378,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2604,9 +2673,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2627,7 +2696,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3049,7 +3117,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4107,7 +4174,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4188,7 +4254,6 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4234,7 +4299,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4267,6 +4331,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4429,7 +4503,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4443,7 +4516,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4579,6 +4651,22 @@ } } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4596,7 +4684,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index dc02209b1..e1ed0e1ed 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,12 @@ "test:watch": "vitest", "start": "npm run server", "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "client": "tsx scripts/cli.ts client", + "test:conformance:server": "test/conformance/scripts/run-server-conformance.sh --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:all": "test/conformance/scripts/run-server-conformance.sh --suite all --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:run": "npx tsx test/conformance/src/everythingServer.ts", + "test:conformance:client": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:client:all": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --suite all --expected-failures test/conformance/conformance-baseline.yml" }, "dependencies": { "@hono/node-server": "^1.19.9", @@ -118,6 +123,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/conformance": "^0.1.11", "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", diff --git a/test/conformance/conformance-baseline.yml b/test/conformance/conformance-baseline.yml new file mode 100644 index 000000000..23d7e75a8 --- /dev/null +++ b/test/conformance/conformance-baseline.yml @@ -0,0 +1,14 @@ +# Known conformance test failures for v1.x +# These are tracked and should be removed as they're fixed. +# +# tools_call: conformance runner's test server reuses a single Server +# instance across requests, triggering v1.26.0's "Already connected" +# guard (GHSA-345p-7cg4-v4c7). Fixed in conformance repo (PR #141), +# remove this entry once a new conformance release is published. +# +# auth/pre-registration: scenario added in conformance 0.1.11 that +# requires a dedicated client handler for pre-registered credentials. +# Needs to be implemented in both v1.x and main. +client: + - tools_call + - auth/pre-registration diff --git a/test/conformance/scripts/run-server-conformance.sh b/test/conformance/scripts/run-server-conformance.sh new file mode 100755 index 000000000..5105d64f7 --- /dev/null +++ b/test/conformance/scripts/run-server-conformance.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Script to run server conformance tests +# Starts the conformance server, runs conformance tests, then stops the server + +set -e + +PORT="${PORT:-3000}" +SERVER_URL="http://localhost:${PORT}/mcp" + +# Navigate to repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start the server in the background +echo "Starting conformance test server on port ${PORT}..." +npx tsx test/conformance/src/everythingServer.ts & +SERVER_PID=$! + +# Function to cleanup on exit +cleanup() { + echo "Stopping server (PID: ${SERVER_PID})..." + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for server to be ready +echo "Waiting for server to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "${SERVER_URL}" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} attempts" + exit 1 + fi + sleep 0.5 +done + +echo "Server is ready. Running conformance tests..." + +# Run conformance tests - pass through all arguments +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@" + +echo "Conformance tests completed." diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts new file mode 100644 index 000000000..bd4c079de --- /dev/null +++ b/test/conformance/src/everythingClient.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * Everything client (v1.x) - a single conformance test client that handles all scenarios. + * + * Usage: everything-client + * + * The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, + * which is set by the conformance test runner. + */ + +import { Client } from '../../../src/client/index.js'; +import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../../src/client/auth-extensions.js'; +import { ElicitRequestSchema } from '../../../src/types.js'; +import { z } from 'zod'; + +import { logger } from './helpers/logger.js'; +import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; + +/** + * Fixed client metadata URL for CIMD conformance tests. + */ +const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json'; + +/** + * Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT. + */ +const ClientConformanceContextSchema = z.discriminatedUnion('name', [ + z.object({ + name: z.literal('auth/client-credentials-jwt'), + client_id: z.string(), + private_key_pem: z.string(), + signing_algorithm: z.string().optional() + }), + z.object({ + name: z.literal('auth/client-credentials-basic'), + client_id: z.string(), + client_secret: z.string() + }) +]); + +/** + * Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var. + */ +function parseContext() { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) { + throw new Error('MCP_CONFORMANCE_CONTEXT not set'); + } + return ClientConformanceContextSchema.parse(JSON.parse(raw)); +} + +// Scenario handler type +type ScenarioHandler = (serverUrl: string) => Promise; + +// Registry of scenario handlers +const scenarioHandlers: Record = {}; + +// Helper to register a scenario handler +function registerScenario(name: string, handler: ScenarioHandler): void { + scenarioHandlers[name] = handler; +} + +// Helper to register multiple scenarios with the same handler +function registerScenarios(names: string[], handler: ScenarioHandler): void { + for (const name of names) { + scenarioHandlers[name] = handler; + } +} + +// ============================================================================ +// Basic scenarios (initialize, tools_call) +// ============================================================================ + +async function runBasicClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// tools_call scenario needs to actually call a tool +async function runToolsCallClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('initialize', runBasicClient); +registerScenario('tools_call', runToolsCallClient); + +// ============================================================================ +// Auth scenarios - well-behaved client +// ============================================================================ + +async function runAuthClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} }); + + const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// Register all auth scenarios that should use the well-behaved auth client +registerScenarios( + [ + 'auth/basic-cimd', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + 'auth/2025-03-26-oauth-metadata-backcompat', + 'auth/2025-03-26-oauth-endpoint-fallback', + 'auth/scope-from-www-authenticate', + 'auth/scope-from-scopes-supported', + 'auth/scope-omitted-when-undefined', + 'auth/scope-step-up', + 'auth/scope-retry-limit', + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none' + ], + runAuthClient +); + +// ============================================================================ +// Client Credentials scenarios +// ============================================================================ + +async function runClientCredentialsJwt(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-jwt') { + throw new Error(`Expected jwt context, got ${ctx.name}`); + } + + const provider = new PrivateKeyJwtProvider({ + clientId: ctx.client_id, + privateKey: ctx.private_key_pem, + algorithm: ctx.signing_algorithm || 'ES256' + }); + + const client = new Client({ name: 'conformance-client-credentials-jwt', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with private_key_jwt auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt); + +async function runClientCredentialsBasic(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-basic') { + throw new Error(`Expected basic context, got ${ctx.name}`); + } + + const provider = new ClientCredentialsProvider({ + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + const client = new Client({ name: 'conformance-client-credentials-basic', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with client_secret_basic auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); + +// ============================================================================ +// Elicitation defaults scenario +// ============================================================================ + +async function runElicitationDefaultsClient(serverUrl: string): Promise { + const client = new Client( + { name: 'elicitation-defaults-test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + // Register elicitation handler that returns empty content + // The SDK should fill in defaults for all omitted fields + client.setRequestHandler(ElicitRequestSchema, async request => { + logger.debug('Received elicitation request:', JSON.stringify(request.params, null, 2)); + logger.debug('Accepting with empty content - SDK should apply defaults'); + + return { + action: 'accept' as const, + content: {} + }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const testTool = tools.tools.find(t => t.name === 'test_client_elicitation_defaults'); + if (!testTool) { + throw new Error('Test tool not found: test_client_elicitation_defaults'); + } + + logger.debug('Calling test_client_elicitation_defaults tool...'); + const result = await client.callTool({ + name: 'test_client_elicitation_defaults', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsClient); + +// ============================================================================ +// SSE retry scenario +// ============================================================================ + +async function runSSERetryClient(serverUrl: string): Promise { + const client = new Client({ name: 'sse-retry-test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const testTool = tools.tools.find(t => t.name === 'test_reconnection'); + if (!testTool) { + throw new Error('Test tool not found: test_reconnection'); + } + + logger.debug('Calling test_reconnection tool...'); + const result = await client.callTool({ + name: 'test_reconnection', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sse-retry', runSSERetryClient); + +// ============================================================================ +// Main entry point +// ============================================================================ + +async function main(): Promise { + const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO; + const serverUrl = process.argv[2]; + + if (!scenarioName || !serverUrl) { + logger.error('Usage: MCP_CONFORMANCE_SCENARIO= everything-client '); + logger.error('\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.'); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + const handler = scenarioHandlers[scenarioName]; + if (!handler) { + logger.error(`Unknown scenario: ${scenarioName}`); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + try { + await handler(serverUrl); + process.exit(0); + } catch (error) { + logger.error('Error:', error); + process.exit(1); + } +} + +try { + await main(); +} catch (error) { + logger.error('Error:', error); + process.exit(1); +} diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts new file mode 100644 index 000000000..05f4cb174 --- /dev/null +++ b/test/conformance/src/everythingServer.ts @@ -0,0 +1,953 @@ +#!/usr/bin/env node + +/** + * MCP Conformance Test Server (v1.x) + * + * Server implementing all MCP features for conformance testing. + * Adapted from the main branch version for the v1.x single-package SDK. + */ + +import { randomUUID } from 'node:crypto'; + +import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js'; +import type { EventId, EventStore, StreamId } from '../../../src/server/streamableHttp.js'; +import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js'; +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '../../../src/types.js'; +import { + CompleteRequestSchema, + CreateMessageResultSchema, + ElicitResultSchema, + isInitializeRequest, + SetLevelRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema +} from '../../../src/types.js'; +import { localhostHostValidation } from '../../../src/server/middleware/hostHeaderValidation.js'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import { z } from 'zod'; + +// Server state +const resourceSubscriptions = new Set(); +const watchedResourceContent = 'Watched resource content'; + +// Session management +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: unknown) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0] || lastEventId; + const eventsToReplay: Array<[string, { message: unknown }]> = []; + for (const [eventId, data] of eventStoreData.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (message && typeof message === 'object' && Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + +// Sample base64 encoded 1x1 red PNG pixel for testing +const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +// Sample base64 encoded minimal WAV file for testing +const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + +// Function to create a new MCP server instance (one per session) +function createMcpServer() { + const mcpServer = new McpServer( + { + name: 'mcp-conformance-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + }, + resources: { + subscribe: true, + listChanged: true + }, + prompts: { + listChanged: true + }, + logging: {}, + completions: {} + } + } + ); + + // Helper to send log messages using the underlying server + function sendLog( + level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', + message: string, + _data?: unknown + ) { + mcpServer.server + .notification({ + method: 'notifications/message', + params: { + level, + logger: 'conformance-test-server', + data: _data || message + } + }) + .catch(() => { + // Ignore error if no client is connected + }); + } + + // ===== TOOLS ===== + + // Simple text tool + mcpServer.tool('test_simple_text', 'Tests simple text content response', async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + }); + + // Image content tool + mcpServer.tool('test_image_content', 'Tests image content response', async (): Promise => { + return { + content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] + }; + }); + + // Audio content tool + mcpServer.tool('test_audio_content', 'Tests audio content response', async (): Promise => { + return { + content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] + }; + }); + + // Embedded resource tool + mcpServer.tool('test_embedded_resource', 'Tests embedded resource content response', async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.' + } + } + ] + }; + }); + + // Multiple content types tool + mcpServer.tool( + 'test_multiple_content_types', + 'Tests response with multiple content types (text, image, resource)', + async (): Promise => { + return { + content: [ + { type: 'text', text: 'Multiple content types test:' }, + { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: JSON.stringify({ test: 'data', value: 123 }) + } + } + ] + }; + } + ); + + // Tool with logging + mcpServer.tool( + 'test_tool_with_logging', + 'Tests tool that emits log messages during execution', + {}, + async (_args, extra): Promise => { + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution started' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool processing data' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution completed' + } + }); + return { + content: [{ type: 'text', text: 'Tool with logging executed successfully' }] + }; + } + ); + + // Tool with progress + mcpServer.tool( + 'test_tool_with_progress', + 'Tests tool that reports progress notifications', + {}, + async (_args, extra): Promise => { + const progressToken = extra._meta?.progressToken ?? 0; + console.log('Progress token:', progressToken); + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 0, + total: 100, + message: `Completed step ${0} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100, + message: `Completed step ${50} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 100, + total: 100, + message: `Completed step ${100} of ${100}` + } + }); + + return { + content: [{ type: 'text', text: String(progressToken) }] + }; + } + ); + + // Error handling tool + mcpServer.tool('test_error_handling', 'Tests error response handling', async (): Promise => { + throw new Error('This tool intentionally returns an error for testing'); + }); + + // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection + mcpServer.tool( + 'test_reconnection', + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting test_reconnection tool...`); + + // Get the transport for this session + const transport = extra.sessionId ? transports[extra.sessionId] : undefined; + if (transport && extra.requestId) { + // Close the SSE stream to trigger client reconnection + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + transport.closeSSEStream(extra.requestId); + } + + // Wait for client to reconnect (should respect retry field) + await sleep(100); + + console.log(`[${extra.sessionId}] test_reconnection tool complete`); + + return { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' + } + ] + }; + } + ); + + // Sampling tool - requests LLM completion from client + mcpServer.tool( + 'test_sampling', + 'Tests server-initiated sampling (LLM completion request)', + { + prompt: z.string() + }, + async (args: { prompt: string }, extra): Promise => { + try { + // Request sampling from client + const result = (await extra.sendRequest( + { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: args.prompt + } + } + ], + maxTokens: 100 + } + }, + CreateMessageResultSchema + )) as { content?: { text?: string }; message?: { content?: { text?: string } } }; + + const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; + + return { + content: [ + { + type: 'text', + text: `LLM response: ${modelResponse}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // Elicitation tool - requests user input from client + mcpServer.tool( + 'test_elicitation', + 'Tests server-initiated elicitation (user input request)', + { + message: z.string().describe('The message to show the user') + }, + async (args: { message: string }, extra): Promise => { + try { + // Request user input from client + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: args.message, + requestedSchema: { + type: 'object', + properties: { + response: { + type: 'string', + description: "User's response" + } + }, + required: ['response'] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1034: Elicitation with default values for all primitive types + mcpServer.tool( + 'test_elicitation_sep1034_defaults', + 'Tests elicitation with default values per SEP-1034', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: 'Please review and update the form fields with defaults', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'User name', + default: 'John Doe' + }, + age: { + type: 'integer', + description: 'User age', + default: 30 + }, + score: { + type: 'number', + description: 'User score', + default: 95.5 + }, + status: { + type: 'string', + description: 'User status', + enum: ['active', 'inactive', 'pending'], + default: 'active' + }, + verified: { + type: 'boolean', + description: 'Verification status', + default: true + } + }, + required: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1330: Elicitation with enum schema improvements + mcpServer.tool( + 'test_elicitation_sep1330_enums', + 'Tests elicitation with enum schema improvements per SEP-1330', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + titledSingle: { + type: 'string', + description: 'Select one option with titles', + oneOf: [ + { const: 'value1', title: 'First Option' }, + { const: 'value2', title: 'Second Option' }, + { const: 'value3', title: 'Third Option' } + ] + }, + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['option1', 'option2', 'option3'] + } + }, + titledMulti: { + type: 'array', + description: 'Select multiple options with titles', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'value1', title: 'First Choice' }, + { const: 'value2', title: 'Second Choice' }, + { const: 'value3', title: 'Third Choice' } + ] + } + } + }, + required: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1613: JSON Schema 2020-12 conformance test tool + const addressSchema = z.object({ + street: z.string().optional(), + city: z.string().optional() + }); + mcpServer.tool( + 'json_schema_2020_12_tool', + 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + { + name: z.string().optional(), + address: addressSchema.optional() + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + return { + content: [ + { + type: 'text', + text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` + } + ] + }; + } + ); + + // ===== RESOURCES ===== + + // Static text resource + mcpServer.registerResource( + 'static-text', + 'test://static-text', + { + title: 'Static Text Resource', + description: 'A static text resource for testing', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'This is the content of the static text resource.' + } + ] + }; + } + ); + + // Static binary resource + mcpServer.registerResource( + 'static-binary', + 'test://static-binary', + { + title: 'Static Binary Resource', + description: 'A static binary resource (image) for testing', + mimeType: 'image/png' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-binary', + mimeType: 'image/png', + blob: TEST_IMAGE_BASE64 + } + ] + }; + } + ); + + // Resource template + mcpServer.registerResource( + 'template', + new ResourceTemplate('test://template/{id}/data', { list: undefined }), + { + title: 'Resource Template', + description: 'A resource template with parameter substitution', + mimeType: 'application/json' + }, + async (uri, variables): Promise => { + const id = variables.id; + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify({ + id, + templateTest: true, + data: `Data for ID: ${id}` + }) + } + ] + }; + } + ); + + // Watched resource + mcpServer.registerResource( + 'watched-resource', + 'test://watched-resource', + { + title: 'Watched Resource', + description: 'A resource that auto-updates every 3 seconds', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://watched-resource', + mimeType: 'text/plain', + text: watchedResourceContent + } + ] + }; + } + ); + + // Subscribe/Unsubscribe handlers + mcpServer.server.setRequestHandler(SubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.add(uri); + sendLog('info', `Subscribed to resource: ${uri}`); + return {}; + }); + + mcpServer.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.delete(uri); + sendLog('info', `Unsubscribed from resource: ${uri}`); + return {}; + }); + + // ===== PROMPTS ===== + + // Simple prompt + mcpServer.prompt('test_simple_prompt', 'A simple prompt without arguments', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' + } + } + ] + }; + }); + + // Prompt with arguments + mcpServer.prompt( + 'test_prompt_with_arguments', + 'A prompt with required arguments', + { + arg1: z.string().describe('First test argument'), + arg2: z.string().describe('Second test argument') + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` + } + } + ] + }; + } + ); + + // Prompt with embedded resource + mcpServer.prompt( + 'test_prompt_with_embedded_resource', + 'A prompt that includes an embedded resource', + { + resourceUri: z.string().describe('URI of the resource to embed') + }, + async (args: { resourceUri: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: args.resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.' + } + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'Please process the embedded resource above.' + } + } + ] + }; + } + ); + + // Prompt with image + mcpServer.prompt('test_prompt_with_image', 'A prompt that includes image content', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'image', + data: TEST_IMAGE_BASE64, + mimeType: 'image/png' + } + }, + { + role: 'user', + content: { type: 'text', text: 'Please analyze the image above.' } + } + ] + }; + }); + + // ===== LOGGING ===== + + mcpServer.server.setRequestHandler(SetLevelRequestSchema, async request => { + const level = request.params.level; + sendLog('info', `Log level set to: ${level}`); + return {}; + }); + + // ===== COMPLETION ===== + + mcpServer.server.setRequestHandler(CompleteRequestSchema, async () => { + return { + completion: { + values: [], + total: 0, + hasMore: false + } + }; + }); + + return mcpServer; +} + +// ===== EXPRESS APP ===== + +const app = express(); +app.use(express.json()); +app.use(localhostHostValidation()); + +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id'] + }) +); + +// Handle POST requests - stateful mode +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Invalid or missing session ID' + }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests - SSE streams for sessions +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing SSE stream for session ${sessionId}`); + } + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Handle DELETE requests - session termination +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); +}); diff --git a/test/conformance/src/helpers/conformanceOAuthProvider.ts b/test/conformance/src/helpers/conformanceOAuthProvider.ts new file mode 100644 index 000000000..7623fcc55 --- /dev/null +++ b/test/conformance/src/helpers/conformanceOAuthProvider.ts @@ -0,0 +1,87 @@ +import type { OAuthClientProvider } from '../../../../src/client/auth.js'; +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../../../src/shared/auth.js'; + +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + private _authCodePromise?: Promise; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadataUrl?: string | URL + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + get clientMetadataUrl(): string | undefined { + return this._clientMetadataUrl?.toString(); + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' + }); + + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } else { + throw new Error('No auth code in redirect URL'); + } + } else { + throw new Error(`No redirect location received, from '${authorizationUrl.toString()}'`); + } + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/test/conformance/src/helpers/logger.ts b/test/conformance/src/helpers/logger.ts new file mode 100644 index 000000000..8de9342bd --- /dev/null +++ b/test/conformance/src/helpers/logger.ts @@ -0,0 +1,27 @@ +/** + * Simple logger with configurable log levels. + * Set to 'error' in tests to suppress debug output. + */ + +export type LogLevel = 'debug' | 'error'; + +let currentLogLevel: LogLevel = 'debug'; + +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +export const logger = { + debug: (...args: unknown[]): void => { + if (currentLogLevel === 'debug') { + console.log(...args); + } + }, + error: (...args: unknown[]): void => { + console.error(...args); + } +}; diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts new file mode 100644 index 000000000..e24c8316f --- /dev/null +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -0,0 +1,81 @@ +import type { FetchLike } from '../../../../src/shared/transport.js'; +import type { Middleware } from '../../../../src/client/middleware.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '../../../../src/client/auth.js'; + +import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; + +export const handle401 = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL +): Promise => { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(`Authentication failed with result: ${result}`); + } + } +}; + +export const withOAuthRetry = ( + clientName: string, + baseUrl?: string | URL, + handle401Fn: typeof handle401 = handle401, + clientMetadataUrl?: string +): Middleware => { + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + }, + clientMetadataUrl + ); + return (next: FetchLike) => { + return async (input: string | URL, init?: RequestInit): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + if (response.status === 401 || response.status === 403) { + const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); + await handle401Fn(response, provider, next, serverUrl); + + response = await makeRequest(); + } + + if (response.status === 401 || response.status === 403) { + const url = typeof input === 'string' ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; +}; diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json new file mode 100644 index 000000000..5d51831c5 --- /dev/null +++ b/test/conformance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + } +} From 825e9ab80332e7fac19d221f6aae352dc73172fe Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:20:54 +0000 Subject: [PATCH 13/43] feat: backport discoverOAuthServerInfo() and discovery caching to v1.x (#1533) --- src/client/auth.ts | 197 +++++++++++++++++++-- test/client/auth.test.ts | 364 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 19 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 4c82b5114..b59862052 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -150,7 +150,7 @@ export interface OAuthClientProvider { * credentials, in the case where the server has indicated that they are no longer valid. * This avoids requiring the user to intervene manually. */ - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -189,6 +189,46 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the OAuth discovery state after RFC 9728 and authorization server metadata + * discovery. Providers can persist this state to avoid redundant discovery requests + * on subsequent {@linkcode auth} calls. + * + * This state can also be provided out-of-band (e.g., from a previous session or + * external configuration) to bootstrap the OAuth flow without discovery. + * + * Called by {@linkcode auth} after successful discovery. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * Returns previously saved discovery state, or `undefined` if none is cached. + * + * When available, {@linkcode auth} restores the discovery state (authorization server + * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing + * latency on subsequent calls. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. + * + * Contains the results of RFC 9728 protected resource metadata discovery and + * authorization server metadata discovery. Persisting this state avoids + * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -397,32 +437,70 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist discovery state for future use + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); - // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -937,6 +1015,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index d6e7e8684..8df325eed 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -8,6 +8,7 @@ import { refreshAuthorization, registerClient, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, extractWWWAuthenticateParams, auth, type OAuthClientProvider, @@ -916,6 +917,369 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = { From 97ab379e4572ac8e38ff8b99891f29a69cfbb5bb Mon Sep 17 00:00:00 2001 From: Valentin Beggi <87306219+valentinbeggi@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:57:53 +0100 Subject: [PATCH 14/43] feat: add url property to RequestInfo interface (#1353) Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> --- .changeset/add-url-to-request-info.md | 5 +++++ src/server/sse.ts | 11 ++++++++++- src/server/webStandardStreamableHttp.ts | 5 +++-- src/types.ts | 4 ++++ test/server/sse.test.ts | 16 ++++++++++++---- test/server/streamableHttp.test.ts | 3 ++- 6 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 .changeset/add-url-to-request-info.md diff --git a/.changeset/add-url-to-request-info.md b/.changeset/add-url-to-request-info.md new file mode 100644 index 000000000..dd3b1d252 --- /dev/null +++ b/.changeset/add-url-to-request-info.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add `url` property to `RequestInfo` interface as a `URL` type, exposing the full request URL to server handlers. The URL is unified across all HTTP transports (SSE and Streamable HTTP) to always provide the complete URL including protocol, host, and path. diff --git a/src/server/sse.ts b/src/server/sse.ts index b7450a09e..4931beae6 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { IncomingMessage, ServerResponse } from 'node:http'; +import { TLSSocket } from 'node:tls'; import { Transport } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; import getRawBody from 'raw-body'; @@ -149,7 +150,15 @@ export class SSEServerTransport implements Transport { } const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; + + const host = req.headers.host; + const protocol = req.socket instanceof TLSSocket ? 'https' : 'http'; + const fullUrl = host && req.url ? new URL(req.url, `${protocol}://${host}`) : undefined; + + const requestInfo: RequestInfo = { + headers: req.headers, + url: fullUrl + }; let body: string | unknown; try { diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index c811c54c4..4943565a1 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -605,9 +605,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json'); } - // Build request info from headers + // Build request info from headers and URL const requestInfo: RequestInfo = { - headers: Object.fromEntries(req.headers.entries()) + headers: Object.fromEntries(req.headers.entries()), + url: new URL(req.url) }; let rawMessage; diff --git a/src/types.ts b/src/types.ts index 6bec5190c..bdd2dfed0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2361,6 +2361,10 @@ export interface RequestInfo { * The headers of the request. */ headers: IsomorphicHeaders; + /** + * The full URL of the request. + */ + url?: URL; } /** diff --git a/test/server/sse.test.ts b/test/server/sse.test.ts index 4686f2ba9..0e996d1d6 100644 --- a/test/server/sse.test.ts +++ b/test/server/sse.test.ts @@ -19,10 +19,15 @@ const createMockResponse = () => { return res as unknown as Mocked; }; -const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { +const createMockRequest = ({ + headers = {}, + body, + url = '/messages' +}: { headers?: Record; body?: string; url?: string } = {}) => { const mockReq = { headers, body: body ? body : undefined, + url, auth: { token: 'test-token' }, @@ -312,7 +317,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': 'node', 'accept-encoding': 'gzip, deflate', 'content-length': '124' - } + }, + url: `http://127.0.0.1:${serverPort}/?sessionId=${sessionId}` }) } ] @@ -387,7 +393,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { id: 1 }); const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, + headers: { host: 'localhost', 'content-type': 'application/json' }, body: validMessage }); const mockRes = createMockResponse(); @@ -416,8 +422,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, requestInfo: { headers: { + host: 'localhost', 'content-type': 'application/json' - } + }, + url: new URL('http://localhost/messages') } } ); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 1c4d5ed84..3968c21a5 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -443,7 +443,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': expect.any(String), 'accept-encoding': expect.any(String), 'content-length': expect.any(String) - } + }, + url: baseUrl.toString() }); }); From 5c16ae3339bfa1dd71b0dee1a534e1b6d8be658e Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Sun, 15 Feb 2026 05:02:27 -0800 Subject: [PATCH 15/43] [v1.x] feat(tasks): add streaming methods for elicitation and sampling (#1528) Co-authored-by: Konstantin Konstantinov --- src/examples/client/simpleStreamableHttp.ts | 89 ++- src/examples/server/simpleStreamableHttp.ts | 109 ++++ src/experimental/tasks/server.ts | 198 +++++- .../tasks/server-streaming.test.ts | 570 ++++++++++++++++++ 4 files changed, 944 insertions(+), 22 deletions(-) create mode 100644 test/experimental/tasks/server-streaming.test.ts diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 21ab4f556..19eb5577a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, ElicitRequestSchema, + ElicitResult, ResourceLink, ReadResourceRequest, ReadResourceResultSchema, @@ -22,6 +23,7 @@ import { ErrorCode, McpError } from '../../types.js'; +import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { Ajv } from 'ajv'; @@ -65,6 +67,7 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); + console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -131,6 +134,11 @@ function commandLoop(): void { await callCollectInfoTool(args[1] || 'contact'); break; + case 'collect-info-task': { + await callCollectInfoWithTask(args[1] || 'contact'); + break; + } + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -232,7 +240,10 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client with form elicitation capability + // Create task store for client-side task support + const clientTaskStore = new InMemoryTaskStore(); + + // Create a new client with form elicitation capability and task support client = new Client( { name: 'example-client', @@ -242,25 +253,46 @@ async function connect(url?: string): Promise { capabilities: { elicitation: { form: {} + }, + tasks: { + requests: { + elicitation: { + create: {} + } + } } - } + }, + taskStore: clientTaskStore } ); client.onerror = error => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); }; - // Set up elicitation request handler with proper validation - client.setRequestHandler(ElicitRequestSchema, async request => { + // Set up elicitation request handler with proper validation and task support + client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { if (request.params.mode !== 'form') { throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); } console.log('\n🔔 Elicitation (form) Request Received:'); console.log(`Message: ${request.params.message}`); console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`); + console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`); console.log('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + // Helper to return result, optionally creating a task if requested + const returnResult = async (result: ElicitResult) => { + if (request.params.task && extra.taskStore) { + // Create a task and store the result + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + console.log(`📋 Created client-side task: ${task.taskId}`); + return { task }; + } + return result; + }; + const schema = request.params.requestedSchema; const properties = schema.properties; const required = schema.required || []; @@ -381,7 +413,7 @@ async function connect(url?: string): Promise { } if (inputCancelled) { - return { action: 'cancel' }; + return returnResult({ action: 'cancel' }); } // If we didn't complete all fields due to an error, try again @@ -394,7 +426,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -412,7 +444,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -426,25 +458,34 @@ async function connect(url?: string): Promise { }); }); - if (confirmAnswer === 'yes' || confirmAnswer === 'y') { - return { - action: 'accept', - content - }; - } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { - return { action: 'cancel' }; - } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { - if (attempts < maxAttempts) { - console.log('Please re-enter the information...'); - continue; - } else { - return { action: 'decline' }; + switch (confirmAnswer) { + case 'yes': + case 'y': { + return returnResult({ + action: 'accept', + content: content as ElicitResult['content'] + }); + } + case 'cancel': + case 'c': { + return returnResult({ action: 'cancel' }); + } + case 'no': + case 'n': { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return returnResult({ action: 'decline' }); + } + + break; } } } console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); }); transport = new StreamableHTTPClientTransport(new URL(serverUrl), { @@ -641,6 +682,12 @@ async function callCollectInfoTool(infoType: string): Promise { await callTool('collect-user-info', { infoType }); } +async function callCollectInfoWithTask(infoType: string): Promise { + console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`); + console.log('This will create a task on the server, which will elicit input and create a task on the client.\n'); + await callToolTask('collect-user-info-task', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index e3b754fa6..e25c67986 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -8,6 +8,7 @@ import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; import { createMcpExpressApp } from '../../server/express.js'; import { CallToolResult, + ElicitResult, ElicitResultSchema, GetPromptResult, isInitializeRequest, @@ -280,6 +281,114 @@ const getServer = () => { } ); + // Register a tool that demonstrates bidirectional task support: + // Server creates a task, then elicits input from client using elicitInputStream + // Using the experimental tasks API - WARNING: may change without notice + server.experimental.tasks.registerToolTask( + 'collect-user-info-task', + { + title: 'Collect Info with Task', + description: 'Collects user info via elicitation with task support using elicitInputStream', + inputSchema: { + infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact') + } + }, + { + async createTask({ infoType }, { taskStore: createTaskStore, taskRequestedTtl }) { + // Create the server-side task + const task = await createTaskStore.createTask({ + ttl: taskRequestedTtl + }); + + // Perform async work that makes a nested elicitation request using elicitInputStream + (async () => { + try { + const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences'; + + // Define schemas with proper typing for PrimitiveSchemaDefinition + const contactSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name', description: 'Your full name' }, + email: { type: 'string', title: 'Email', description: 'Your email address' } + }, + required: ['name', 'email'] + }; + + const preferencesSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + }; + + const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema; + + // Use elicitInputStream to elicit input from client + // This demonstrates the streaming elicitation API + // Access via server.server to get the underlying Server instance + const stream = server.server.experimental.tasks.elicitInputStream({ + mode: 'form', + message, + requestedSchema + }); + + let elicitResult: ElicitResult | undefined; + for await (const msg of stream) { + if (msg.type === 'result') { + elicitResult = msg.result as ElicitResult; + } else if (msg.type === 'error') { + throw msg.error; + } + } + + if (!elicitResult) { + throw new Error('No result received from elicitation'); + } + + let resultText: string; + if (elicitResult.action === 'accept') { + resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`; + } else if (elicitResult.action === 'decline') { + resultText = `User declined to provide ${infoType} information`; + } else { + resultText = 'User cancelled the request'; + } + + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: resultText }] + }); + } catch (error) { + console.error('Error in collect-user-info-task:', error); + await taskStore.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } + })(); + + return { task }; + }, + async getTask(_args, { taskId, taskStore: getTaskStore }) { + return await getTaskStore.getTask(taskId); + }, + async getTaskResult(_args, { taskId, taskStore: getResultTaskStore }) { + const result = await getResultTaskStore.getTaskResult(taskId); + return result as CallToolResult; + } + } + ); + // Register a simple prompt with title server.registerPrompt( 'greeting-template', diff --git a/src/experimental/tasks/server.ts b/src/experimental/tasks/server.ts index a4150a8d7..e77ad2582 100644 --- a/src/experimental/tasks/server.ts +++ b/src/experimental/tasks/server.ts @@ -9,7 +9,21 @@ import type { Server } from '../../server/index.js'; import type { RequestOptions } from '../../shared/protocol.js'; import type { ResponseMessage } from '../../shared/responseMessage.js'; import type { AnySchema, SchemaOutput } from '../../server/zod-compat.js'; -import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '../../types.js'; +import type { + ServerRequest, + Notification, + Request, + Result, + GetTaskResult, + ListTasksResult, + CancelTaskResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult +} from '../../types.js'; +import { CreateMessageResultSchema, ElicitResultSchema } from '../../types.js'; /** * Experimental task features for low-level MCP servers. @@ -60,6 +74,188 @@ export class ExperimentalServerTasks< return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); } + /** + * Sends a sampling request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages + * before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.createMessageStream({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + * maxTokens: 100 + * }, { + * onprogress: (progress) => { + * // Handle streaming tokens via progress notifications + * console.log('Progress:', progress.message); + * } + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The sampling request parameters + * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + createMessageStream( + params: CreateMessageRequestParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + + // Capability check - only required when tools/toolChoice are provided + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new Error('Client does not support sampling tools capability.'); + } + + // Message structure validation - always validate tool_use/tool_result pairs. + // These may appear even without tools/toolChoice in the current request when + // a previous sampling request returned tool_use and this is a follow-up with results. + if (params.messages.length > 0) { + const lastMessage = params.messages[params.messages.length - 1]; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + + const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new Error('The last message must contain only tool_result content if any is present'); + } + if (!hasPreviousToolUse) { + throw new Error('tool_result blocks are not matching any tool_use from the previous message'); + } + } + if (hasPreviousToolUse) { + // Extract tool_use IDs from previous message and tool_result IDs from current message + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as { id: string }).id)); + const toolResultIds = new Set( + lastContent.filter(c => c.type === 'tool_result').map(c => (c as { toolUseId: string }).toolUseId) + ); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match'); + } + } + } + + return this.requestStream( + { + method: 'sampling/createMessage', + params + }, + CreateMessageResultSchema, + options + ); + } + + /** + * Sends an elicitation request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' + * and 'taskStatus' messages before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.elicitInputStream({ + * mode: 'url', + * message: 'Please authenticate', + * elicitationId: 'auth-123', + * url: 'https://example.com/auth' + * }, { + * task: { ttl: 300000 } // Task-augmented for long-running auth flow + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('User action:', message.result.action); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The elicitation request parameters + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + elicitInputStream( + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + const mode = params.mode ?? 'form'; + + // Capability check based on mode + switch (mode) { + case 'url': { + if (!clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support url elicitation.'); + } + break; + } + case 'form': { + if (!clientCapabilities?.elicitation?.form) { + throw new Error('Client does not support form elicitation.'); + } + break; + } + } + + // Normalize params to ensure mode is set for form mode (defaults to 'form' per spec) + const normalizedParams = mode === 'form' && params.mode === undefined ? { ...params, mode: 'form' as const } : params; + + // Cast to ServerRequest needed because TypeScript can't narrow the union type + // based on the discriminated 'method' field when constructing the object literal + return this.requestStream( + { + method: 'elicitation/create', + params: normalizedParams + } as ServerRequest, + ElicitResultSchema, + options + ); + } + /** * Gets the current status of a task. * diff --git a/test/experimental/tasks/server-streaming.test.ts b/test/experimental/tasks/server-streaming.test.ts new file mode 100644 index 000000000..938005b64 --- /dev/null +++ b/test/experimental/tasks/server-streaming.test.ts @@ -0,0 +1,570 @@ +/** + * Tests for experimental server streaming methods: createMessageStream and elicitInputStream. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Client } from '../../../src/client/index.js'; +import { Server } from '../../../src/server/index.js'; +import { InMemoryTransport } from '../../../src/inMemory.js'; +import { InMemoryTaskStore } from '../../../src/experimental/tasks/stores/in-memory.js'; +import { toArrayAsync } from '../../../src/shared/responseMessage.js'; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + type CreateMessageResult, + type ElicitResult, + type Task +} from '../../../src/types.js'; + +describe('createMessageStream', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + }).toThrow('Client does not support sampling tools capability'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [ + { role: 'user', content: { type: 'text', text: 'Hello' } }, + { + role: 'user', + content: [{ type: 'tool_result', toolUseId: 'test-id', content: [{ type: 'text', text: 'result' }] }] + } + ], + maxTokens: 100 + }); + }).toThrow('tool_result blocks are not matching any tool_use from the previous message'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + describe('terminal message guarantees', () => { + test('should yield exactly one terminal message for successful request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('result'); + + const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield error as terminal message when client returns error', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => { + throw new Error('Simulated client error'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield exactly one terminal message with result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).content).toBeDefined(); + } + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('non-task request minimality', () => { + test('should yield only result message for non-task request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + expect(messages.length).toBe(1); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + sampling: {}, + tasks: { + requests: { + sampling: { createMessage: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { + const result = { + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Task response' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream( + { + messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }], + maxTokens: 100 + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).model).toBe('test-model'); + } + + clientTaskStore.cleanup(); + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); +}); + +describe('elicitInputStream', () => { + let server: Server; + let client: Client; + let clientTransport: ReturnType[0]; + let serverTransport: ReturnType[1]; + + beforeEach(async () => { + server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {}, + url: {} + } + } + } + ); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await server.close().catch(() => {}); + await client.close().catch(() => {}); + }); + + test('should throw when client does not support form elicitation', async () => { + // Create client without form elicitation capability + const noFormClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const [noFormClientTransport, noFormServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noFormClient.connect(noFormClientTransport), server.connect(noFormServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { type: 'object', properties: {} } + }); + }).toThrow('Client does not support form elicitation.'); + + await noFormClient.close().catch(() => {}); + }); + + test('should throw when client does not support url elicitation', async () => { + // Create client without url elicitation capability + const noUrlClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [noUrlClientTransport, noUrlServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noUrlClient.connect(noUrlClientTransport), server.connect(noUrlServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'url', + message: 'Open URL', + elicitationId: 'test-123', + url: 'https://example.com/auth' + }); + }).toThrow('Client does not support url elicitation.'); + + await noUrlClient.close().catch(() => {}); + }); + + test('should default to form mode when mode is not specified', async () => { + const requestStreamSpy = vi.spyOn(server.experimental.tasks, 'requestStream'); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { value: 'test' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call without explicit mode - need to cast because TypeScript expects mode + const params = { + message: 'Enter value', + requestedSchema: { + type: 'object' as const, + properties: { value: { type: 'string' as const } } + } + }; + + const stream = server.experimental.tasks.elicitInputStream( + params as Parameters[0] + ); + await toArrayAsync(stream); + + // Verify mode was normalized to 'form' + expect(requestStreamSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'elicitation/create', + params: expect.objectContaining({ mode: 'form' }) + }), + expect.anything(), + undefined + ); + }); + + test('should yield error as terminal message when client returns error', async () => { + client.setRequestHandler(ElicitRequestSchema, () => { + throw new Error('Simulated client error'); + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + }); + + // For any streaming elicitation request, the AsyncGenerator yields exactly one terminal + // message (either 'result' or 'error') as its final message. + describe('terminal message guarantees', () => { + test.each([ + { action: 'accept' as const, content: { data: 'test-value' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield exactly one terminal message for action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Test message', + requestedSchema: { + type: 'object', + properties: { data: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Count terminal messages (result or error) + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + // Verify terminal message is the last message + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + // Verify result content matches expected action + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe(action); + } + }); + }); + + // For any non-task elicitation request, the generator yields exactly one 'result' message + // (or 'error' if the request fails), with no 'taskCreated' or 'taskStatus' messages. + describe('non-task request minimality', () => { + test.each([ + { action: 'accept' as const, content: { value: 'test' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield only result message for non-task request with action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Non-task request (no task option) + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Non-task request', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Verify no taskCreated or taskStatus messages + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + // Verify exactly one result message + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + // Verify total message count is 1 + expect(messages.length).toBe(1); + }); + }); + + // For any task-augmented elicitation request, the generator should yield at least one + // 'taskCreated' message followed by 'taskStatus' messages before yielding the final + // result or error. + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const taskClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { form: {} }, + tasks: { + requests: { + elicitation: { create: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + taskClient.setRequestHandler(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept' as const, + content: { username: 'task-user' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); + + const stream = server.experimental.tasks.elicitInputStream( + { + mode: 'form', + message: 'Task-augmented request', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'] + } + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe('accept'); + expect((lastMessage.result as ElicitResult).content).toEqual({ username: 'task-user' }); + } + + clientTaskStore.cleanup(); + await taskClient.close().catch(() => {}); + }); + }); +}); From 8cbc65848388cb0364122f5760cb6b01ff8a3654 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:39:03 +0000 Subject: [PATCH 16/43] chore: bump version for v1.27.0 (#1541) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cca035cbd..b53cd04ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index e1ed0e1ed..cadddec62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From f2d21458ccccd7cfaa1a2a171a262961591d4d0b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:27:46 +0000 Subject: [PATCH 17/43] feat: implement auth/pre-registration conformance scenario (#1545) --- .github/workflows/conformance.yml | 4 +- package-lock.json | 208 +++++++++++++++++- package.json | 2 +- test/conformance/conformance-baseline.yml | 14 +- test/conformance/src/everythingClient.ts | 43 ++++ .../conformance/src/helpers/withOAuthRetry.ts | 21 +- 6 files changed, 265 insertions(+), 27 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 974a84cdb..9d049ec3b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - run: npm ci - run: npm run build @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - run: npm ci - run: npm run build diff --git a/package-lock.json b/package-lock.json index b53cd04ed..54ef65919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", - "@modelcontextprotocol/conformance": "^0.1.11", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -737,13 +737,14 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.11.tgz", - "integrity": "sha512-cqayAmyTUhnRsyrOuTqZ+kCc2w/goppxnqZ+XrOsVd/M25No/HiZ1GbZI92sFA7ONYzonqRja56G9IiISIns3A==", + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.14.tgz", + "integrity": "sha512-CNl/7d+yHfXExPDUsNG/kO4t2iLamqLzvsFxscTT3pbP4utbnDvc6lfvLjM3TLrjupY4Iq5FURmTzhsCstA3sw==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", + "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -810,6 +811,172 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -1928,6 +2095,13 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -2752,6 +2926,23 @@ "express": ">= 4.11" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4348,6 +4539,13 @@ "dev": true, "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index cadddec62..e81f5f88e 100644 --- a/package.json +++ b/package.json @@ -123,8 +123,8 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@modelcontextprotocol/conformance": "^0.1.11", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", diff --git a/test/conformance/conformance-baseline.yml b/test/conformance/conformance-baseline.yml index 23d7e75a8..e4ae46cb0 100644 --- a/test/conformance/conformance-baseline.yml +++ b/test/conformance/conformance-baseline.yml @@ -1,14 +1,8 @@ # Known conformance test failures for v1.x # These are tracked and should be removed as they're fixed. # -# tools_call: conformance runner's test server reuses a single Server -# instance across requests, triggering v1.26.0's "Already connected" -# guard (GHSA-345p-7cg4-v4c7). Fixed in conformance repo (PR #141), -# remove this entry once a new conformance release is published. -# -# auth/pre-registration: scenario added in conformance 0.1.11 that -# requires a dedicated client handler for pre-registered credentials. -# Needs to be implemented in both v1.x and main. +# auth/cross-app-access-complete-flow: SEP-990 Enterprise Managed OAuth +# scenario added in conformance 0.1.14. Requires implementing token +# exchange (RFC 8693) and JWT bearer grant (RFC 7523) in the client. client: - - tools_call - - auth/pre-registration + - auth/cross-app-access-complete-flow diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index bd4c079de..002449f29 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -16,6 +16,7 @@ import { ElicitRequestSchema } from '../../../src/types.js'; import { z } from 'zod'; import { logger } from './helpers/logger.js'; +import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider.js'; import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; /** @@ -37,6 +38,11 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/client-credentials-basic'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/pre-registration'), + client_id: z.string(), + client_secret: z.string() }) ]); @@ -228,6 +234,43 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Pre-registration scenario (no dynamic client registration) +// ============================================================================ + +async function runPreRegistrationClient(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + // Create a provider pre-populated with registered credentials, + // so the SDK skips dynamic client registration. + const provider = new ConformanceOAuthProvider('http://localhost:3000/callback', { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + }); + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] + }); + + const oauthFetch = withOAuthRetry('conformance-pre-registration', new URL(serverUrl), handle401, undefined, provider)(fetch); + + const client = new Client({ name: 'conformance-pre-registration', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + await client.listTools(); + await client.callTool({ name: 'test-tool', arguments: {} }); + await transport.close(); +} + +registerScenario('auth/pre-registration', runPreRegistrationClient); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index e24c8316f..1112bb710 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -38,16 +38,19 @@ export const withOAuthRetry = ( clientName: string, baseUrl?: string | URL, handle401Fn: typeof handle401 = handle401, - clientMetadataUrl?: string + clientMetadataUrl?: string, + existingProvider?: ConformanceOAuthProvider ): Middleware => { - const provider = new ConformanceOAuthProvider( - 'http://localhost:3000/callback', - { - client_name: clientName, - redirect_uris: ['http://localhost:3000/callback'] - }, - clientMetadataUrl - ); + const provider = + existingProvider ?? + new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + }, + clientMetadataUrl + ); return (next: FetchLike) => { return async (input: string | URL, init?: RequestInit): Promise => { const makeRequest = async (): Promise => { From 2084a22074d4c8fd54ddc8637783fb10c13edf90 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:21:23 +0000 Subject: [PATCH 18/43] docs: add governance documentation for SEP-1730 (#1547) --- .github/dependabot.yml | 10 ++++++++++ DEPENDENCY_POLICY.md | 29 +++++++++++++++++++++++++++++ ROADMAP.md | 22 ++++++++++++++++++++++ VERSIONING.md | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 DEPENDENCY_POLICY.md create mode 100644 ROADMAP.md create mode 100644 VERSIONING.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1b0059283 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: monthly + groups: + github-actions: + patterns: + - '*' diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..6091066cf --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,29 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP TypeScript SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts). +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Node.js version the SDK still targets. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run scheduled version bumps for npm dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for npm packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`). + +## Pinning and Ranges + +Production dependencies use caret ranges (`^`) to allow compatible updates within a major version. Exact versions are pinned only when necessary to work around a specific issue. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..9f9bb31e0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,22 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). + +## Current Focus Areas + +### Next Spec Revision + +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports. + +The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision. + +### v2 + +A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones: + +- **Alpha**: ~mid-March 2026 +- **Beta**: ~May 2026 + +The v2 release is planned to align with the next spec release, expected around mid-2026. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..5384f09ef --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,40 @@ +# Versioning Policy + +The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) follows [Semantic Versioning 2.0.0](https://semver.org/). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API export (class, function, type, or constant). +- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public type or interface field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Node.js LTS version. +- Removing support for a transport type. +- Changes to the MCP protocol version that require client/server code changes. + +The following are **not** considered breaking: + +- Adding new optional parameters to existing functions. +- Adding new exports, types, or interfaces. +- Adding new optional fields to existing types. +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec features. +- Changes to dev dependencies or build tooling. + +## How Breaking Changes Are Communicated + +1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `@deprecated` JSDoc annotations, which surface warnings through TypeScript tooling and editors. +3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. From 342ea394ca6e660e294162efdeafc411284bcc0d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:22:16 +0000 Subject: [PATCH 19/43] docs: comprehensive feature documentation for SEP-1730 Tier 1 (#1548) --- README.md | 1 + docs/capabilities.md | 112 ++++++- docs/client.md | 50 +++ docs/protocol.md | 200 ++++++++++++ docs/server.md | 303 +++++++++++++++++- src/examples/server/elicitationFormExample.ts | 23 ++ src/examples/server/progressExample.ts | 58 ++++ 7 files changed, 742 insertions(+), 5 deletions(-) create mode 100644 docs/protocol.md create mode 100644 src/examples/server/progressExample.ts diff --git a/README.md b/README.md index 254671c8f..7a553ebbd 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ For more details on how to run these examples (including recommended commands an - [docs/server.md](docs/server.md) – building and running MCP servers, transports, tools/resources/prompts, CORS, DNS rebinding, and multi-node deployment. - [docs/client.md](docs/client.md) – using the high-level client, transports, backwards compatibility, and OAuth helpers. - [docs/capabilities.md](docs/capabilities.md) – sampling, elicitation (form and URL), and experimental task-based execution. + - [docs/protocol.md](docs/protocol.md) – protocol features: ping, progress, cancellation, pagination, capability negotiation, and JSON Schema. - [docs/faq.md](docs/faq.md) – environment and troubleshooting FAQs (including Node.js Web Crypto support). - External references: - [Model Context Protocol documentation](https://modelcontextprotocol.io) diff --git a/docs/capabilities.md b/docs/capabilities.md index 301e850fe..d436a00cd 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -27,6 +27,99 @@ Runnable example: The `simpleStreamableHttp` server also includes a `collect-user-info` tool that demonstrates how to drive elicitation from a tool and handle the response. +#### Schema validation + +Elicitation schemas support validation constraints on each field. The server validates responses automatically against the `requestedSchema` using the SDK's JSON Schema validator. + +```typescript +const result = await server.server.elicitInput({ + mode: 'form', + message: 'Enter your details:', + requestedSchema: { + type: 'object', + properties: { + email: { + type: 'string', + title: 'Email', + format: 'email', + minLength: 5 + }, + age: { + type: 'integer', + title: 'Age', + minimum: 0, + maximum: 150 + } + }, + required: ['email'] + } +}); +``` + +String fields support `minLength`, `maxLength`, and `format` (`'email'`, `'uri'`, `'date'`, `'date-time'`). Number fields support `minimum` and `maximum`. + +#### Default values + +Schema properties can include `default` values. When the client declares the `applyDefaults` capability, the SDK automatically fills in defaults for fields the user doesn't provide. + +> **Note:** `applyDefaults` is a TypeScript SDK extension — it is not part of the MCP protocol specification. + +```typescript +// Client declares applyDefaults: +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { capabilities: { elicitation: { form: { applyDefaults: true } } } } +); + +// Server schema with defaults: +requestedSchema: { + type: 'object', + properties: { + newsletter: { type: 'boolean', title: 'Newsletter', default: false }, + theme: { type: 'string', title: 'Theme', default: 'dark' } + } +} +``` + +#### Enum values + +Elicitation schemas support several enum patterns for single-select and multi-select fields: + +```typescript +requestedSchema: { + type: 'object', + properties: { + // Simple enum (untitled options) + color: { + type: 'string', + title: 'Favorite Color', + enum: ['red', 'green', 'blue'], + default: 'blue' + }, + // Titled enum with display labels + priority: { + type: 'string', + title: 'Priority', + oneOf: [ + { const: 'low', title: 'Low Priority' }, + { const: 'medium', title: 'Medium Priority' }, + { const: 'high', title: 'High Priority' } + ] + }, + // Multi-select + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string', enum: ['frontend', 'backend', 'docs'] }, + minItems: 1, + maxItems: 3 + } + } +} +``` + +For a full example with validation, defaults, and enums, see [`elicitationFormExample.ts`](../src/examples/server/elicitationFormExample.ts). + ### URL elicitation URL elicitation is designed for sensitive data and secure web‑based flows (e.g., collecting an API key, confirming a payment, or doing third‑party OAuth). Instead of returning form data, the server asks the client to open a URL and the rest of the flow happens in the browser. @@ -46,6 +139,23 @@ Key points: Sensitive information **must not** be collected via form elicitation; always use URL elicitation or out‑of‑band flows for secrets. +#### Complete notification + +When a URL elicitation flow finishes (the user completes the browser-based action), the server sends a `notifications/elicitation/complete` notification to the client. This tells the client the out-of-band flow is done and any pending UI can be dismissed. + +Use `createElicitationCompletionNotifier` on the low-level server to create a callback that sends this notification: + +```typescript +// Create a notifier for a specific elicitation: +const notifyComplete = server.server.createElicitationCompletionNotifier('setup-123'); + +// Later, when the browser flow completes (e.g. via webhook): +await notifyComplete(); +// Client receives: { method: 'notifications/elicitation/complete', params: { elicitationId: 'setup-123' } } +``` + +See [`elicitationUrlExample.ts`](../src/examples/server/elicitationUrlExample.ts) for a full working example. + ## Task-based execution (experimental) Task-based execution enables “call-now, fetch-later” patterns for long-running operations. Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. @@ -70,7 +180,7 @@ For a runnable example that uses the in-memory store shipped with the SDK, see: On the client, you use: - `client.experimental.tasks.callToolStream(...)` to start a tool call that may create a task and emit status updates over time. -- `client.getTask(...)` and `client.getTaskResult(...)` to check status and fetch results after reconnecting. +- `client.experimental.tasks.getTask(...)` and `client.experimental.tasks.getTaskResult(...)` to check status and fetch results after reconnecting. The interactive client in: diff --git a/docs/client.md b/docs/client.md index d28765fd0..8f1c65327 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,3 +58,53 @@ These examples show how to: - Perform dynamic client registration if needed. - Acquire access tokens. - Attach OAuth credentials to Streamable HTTP requests. + +## stdio transport + +Use `StdioClientTransport` to connect to a server that runs as a local child process: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'production' }, + cwd: '/path/to/server' +}); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); +await client.connect(transport); +// connect() calls transport.start() automatically, spawning the child process +``` + +The transport communicates over the child process's stdin/stdout using JSON-RPC. The `stderr` option controls where the child's stderr goes (defaults to `'inherit'`). + +## Roots + +Roots let a client expose filesystem locations to the server, so the server knows which directories or files are relevant. Declare the `roots` capability and register a handler: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); + +client.setRequestHandler(ListRootsRequestSchema, async () => { + return { + roots: [ + { uri: 'file:///home/user/project', name: 'My Project' }, + { uri: 'file:///home/user/data', name: 'Data Directory' } + ] + }; +}); +``` + +When the set of roots changes, notify the server so it can re-query: + +```typescript +await client.sendRootsListChanged(); +``` + +Root URIs must use the `file://` scheme. The `listChanged: true` capability flag is required to send change notifications. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 000000000..2773e5e8d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,200 @@ +## Protocol features + +This page covers cross-cutting protocol mechanics that apply to both clients and servers. + +## Ping + +Both client and server expose a `ping()` method for health checks. The remote side responds automatically — no handler registration is needed. + +```typescript +// Client pinging the server: +await client.ping(); + +// With a timeout (milliseconds): +await client.ping({ timeout: 5000 }); + +// Server pinging the client (via the low-level server, no timeout option): +await server.server.ping(); +``` + +## Progress notifications + +Long-running requests can report progress to the caller. The SDK handles `progressToken` assignment automatically when you provide an `onprogress` callback. + +**Receiving progress** (client side): + +```typescript +const result = await client.callTool({ name: 'long-task', arguments: {} }, CallToolResultSchema, { + onprogress: progress => { + // progress has: { progress: number, total?: number, message?: string } + console.log(`${progress.progress}/${progress.total}: ${progress.message}`); + }, + timeout: 30000, + resetTimeoutOnProgress: true +}); +``` + +**Sending progress** (server side, from a tool handler): + +```typescript +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number() } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); +``` + +For a runnable example, see [`progressExample.ts`](../src/examples/server/progressExample.ts). + +## Cancellation + +Requests can be cancelled by the caller using an `AbortSignal`. The SDK sends a `notifications/cancelled` message to the remote side and aborts the handler via its `signal`. + +**Client cancelling a request**: + +```typescript +const controller = new AbortController(); + +const resultPromise = client.callTool({ name: 'slow-tool', arguments: {} }, CallToolResultSchema, { signal: controller.signal }); + +// Cancel after 5 seconds: +setTimeout(() => controller.abort('User cancelled'), 5000); +``` + +**Server handler responding to cancellation**: + +```typescript +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + for (let i = 0; i < 100; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: 'Cancelled' }], isError: true }; + } + await doWork(); + } + return { content: [{ type: 'text', text: 'Done' }] }; +}); +``` + +## Pagination + +All list methods (`listTools`, `listPrompts`, `listResources`, `listResourceTemplates`) support cursor-based pagination. Pass `cursor` from the previous response's `nextCursor` to fetch the next page. + +```typescript +let cursor: string | undefined; +const allTools: Tool[] = []; + +do { + const result = await client.listTools({ cursor }); + allTools.push(...result.tools); + cursor = result.nextCursor; +} while (cursor); +``` + +The same pattern applies to `listPrompts`, `listResources`, and `listResourceTemplates`. + +## Capability negotiation + +Both client and server declare their capabilities during the `initialize` handshake. The SDK enforces these — attempting to use an undeclared capability throws an error. + +**Client capabilities** are set at construction time: + +```typescript +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + roots: { listChanged: true }, + sampling: {}, + elicitation: { form: {} } + } + } +); +``` + +After connecting, inspect what the server supports: + +```typescript +await client.connect(transport); + +const caps = client.getServerCapabilities(); +if (caps?.tools) { + const tools = await client.listTools(); +} +if (caps?.resources?.subscribe) { + // server supports resource subscriptions +} +``` + +**Server capabilities** are inferred from registered handlers. When using `McpServer`, capabilities are set automatically based on what you register (tools, resources, prompts). With the low-level `Server`, you declare them in the constructor. + +## Protocol version negotiation + +The SDK automatically negotiates protocol versions during `initialize`. The client sends `LATEST_PROTOCOL_VERSION` and the server responds with the highest mutually supported version. + +Supported versions are defined in `SUPPORTED_PROTOCOL_VERSIONS` (currently `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07`). If the server responds with an unsupported version, the client throws an error. + +Version negotiation is handled automatically by `client.connect()`. After connecting, you can inspect the result: + +```typescript +await client.connect(transport); + +const serverVersion = client.getServerVersion(); +// { name: 'my-server', version: '1.0.0' } + +const serverCaps = client.getServerCapabilities(); +// { tools: { listChanged: true }, resources: { subscribe: true }, ... } +``` + +## JSON Schema 2020-12 + +MCP uses JSON Schema 2020-12 for tool input and output schemas. When using `McpServer` with Zod, schemas are converted to JSON Schema automatically: + +```typescript +server.registerTool( + 'calculate', + { + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() } + }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) +); +``` + +With the low-level `Server`, you provide JSON Schema directly: + +```typescript +{ + name: 'calculate', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + required: ['a', 'b'] + } +} +``` + +The SDK validates tool outputs against `outputSchema` (when provided) using a pluggable JSON Schema validator. The default validator uses Ajv; a Cloudflare Workers-compatible alternative is available via `CfWorkerJsonSchemaValidator`. diff --git a/docs/server.md b/docs/server.md index fb0766d5b..7dbf64290 100644 --- a/docs/server.md +++ b/docs/server.md @@ -45,6 +45,23 @@ Examples: - Stateless Streamable HTTP: [`simpleStatelessStreamableHttp.ts`](../src/examples/server/simpleStatelessStreamableHttp.ts) - Stateful with resumability: [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) +### stdio + +For local integrations where the client spawns the server as a child process, use `StdioServerTransport`. Communication happens over stdin/stdout using JSON-RPC: + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +// ... register tools, resources, prompts ... + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This is the simplest transport — no HTTP server setup required. The client uses `StdioClientTransport` to spawn and communicate with the server process (see [docs/client.md](client.md#stdio-transport)). + ### Deprecated HTTP + SSE The older HTTP+SSE transport (protocol version 2024‑11‑05) is supported only for backwards compatibility. New implementations should prefer Streamable HTTP. @@ -128,11 +145,91 @@ This snippet is illustrative only; for runnable servers that expose tools, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) - [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) +#### Image and audio results + +Tools can return image and audio content alongside text. Use base64-encoded data with the appropriate MIME type: + +```typescript +// e.g. const chartPngBase64 = fs.readFileSync('chart.png').toString('base64'); +server.registerTool('generate-chart', { description: 'Generate a chart image' }, async () => ({ + content: [ + { + type: 'image', + data: chartPngBase64, + mimeType: 'image/png' + } + ] +})); + +// e.g. const audioBase64 = fs.readFileSync('speech.wav').toString('base64'); +server.registerTool( + 'text-to-speech', + { + description: 'Convert text to speech', + inputSchema: { text: z.string() } + }, + async ({ text }) => ({ + content: [ + { + type: 'audio', + data: audioBase64, + mimeType: 'audio/wav' + } + ] + }) +); +``` + +#### Embedded resource results + +Tools can return embedded resources, allowing the tool to attach full resource objects in its response: + +```typescript +server.registerTool('fetch-data', { description: 'Fetch and return data as a resource' }, async () => ({ + content: [ + { + type: 'resource', + resource: { + uri: 'data://result', + mimeType: 'application/json', + text: JSON.stringify({ key: 'value' }) + } + } + ] +})); +``` + +#### Error handling + +To indicate that a tool call failed, set `isError: true` in the result. The content describes what went wrong: + +```typescript +server.registerTool('risky-operation', { description: 'An operation that might fail' }, async () => { + try { + const result = await doSomething(); + return { content: [{ type: 'text', text: result }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true + }; + } +}); +``` + +#### Tool change notifications + +When tools are added, removed, or updated at runtime, the server automatically notifies connected clients. This happens when you call `registerTool()`, or use `remove()`, `enable()`, `disable()`, or `update()` on a `RegisteredTool`. You can also trigger it manually: + +```typescript +server.sendToolListChanged(); +``` + #### ResourceLink outputs Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need. -The README’s `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. +The README's `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. ### Resources @@ -155,7 +252,70 @@ server.registerResource( ); ``` -Dynamic resources use `ResourceTemplate` and can support completions on path parameters. For full runnable examples of resources: +#### Binary resources + +Resources can return binary data using `blob` (base64-encoded) instead of `text`: + +```typescript +server.registerResource('logo', 'images://logo.png', { title: 'Logo', mimeType: 'image/png' }, async uri => ({ + contents: [{ uri: uri.href, blob: logoPngBase64 }] +})); +``` + +#### Resource templates + +Dynamic resources use `ResourceTemplate` to match URI patterns. The template parameters are passed to the read callback: + +```typescript +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +server.registerResource('user-profile', new ResourceTemplate('users://{userId}/profile', { list: undefined }), { title: 'User Profile', mimeType: 'application/json' }, async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify(await getUser(userId)) + } + ] +})); +``` + +#### Subscribing and unsubscribing + +Clients can subscribe to resource changes. The server declares subscription support via the `resources.subscribe` capability, which `McpServer` enables automatically when resources are registered. + +To handle subscriptions, register handlers on the low-level server for `SubscribeRequestSchema` and `UnsubscribeRequestSchema`: + +```typescript +import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const subscriptions = new Set(); + +server.server.setRequestHandler(SubscribeRequestSchema, async request => { + subscriptions.add(request.params.uri); + return {}; +}); + +server.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + subscriptions.delete(request.params.uri); + return {}; +}); +``` + +When a subscribed resource changes, notify the client: + +```typescript +if (subscriptions.has(resourceUri)) { + await server.server.sendResourceUpdated({ uri: resourceUri }); +} +``` + +Resource list changes (adding/removing resources) are notified automatically when using `registerResource()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendResourceListChanged(); +``` + +For full runnable examples of resources: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) @@ -187,15 +347,150 @@ server.registerPrompt( ); ``` +#### Image content in prompts + +Prompts can include image content in their messages: + +```typescript +server.registerPrompt( + 'analyze-image', + { + title: 'Analyze Image', + description: 'Analyze an image', + argsSchema: { imageBase64: z.string() } + }, + ({ imageBase64 }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'image', + data: imageBase64, + mimeType: 'image/png' + } + } + ] + }) +); +``` + +#### Embedded resources in prompts + +Prompts can embed resource content in their messages: + +```typescript +server.registerPrompt( + 'summarize-doc', + { + title: 'Summarize Document', + description: 'Summarize a document resource' + }, + () => ({ + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: 'docs://readme', + mimeType: 'text/plain', + text: 'Document content here...' + } + } + } + ] + }) +); +``` + +#### Prompt change notifications + +Like tools, prompt list changes are notified automatically when using `registerPrompt()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendPromptListChanged(); +``` + For prompts integrated into a full server, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) ### Completions -Both prompts and resources can support argument completions. On the client side, you use `client.complete()` with a reference to the prompt or resource and the partially‑typed argument. +Both prompts and resources can support argument completions using the `completable` wrapper. This lets clients offer autocomplete suggestions as users type. + +```typescript +import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; + +server.registerPrompt( + 'greet', + { + title: 'Greeting', + description: 'Generate a greeting', + argsSchema: { + name: completable(z.string(), value => { + // Return suggestions matching the partial input + const names = ['Alice', 'Bob', 'Charlie']; + return names.filter(n => n.toLowerCase().startsWith(value.toLowerCase())); + }) + } + }, + ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello, ${name}!` } }] + }) +); +``` + +Resource templates also support completions on their path parameters via `completable`. On the client side, use `client.complete()` with a reference to the prompt or resource and the partially-typed argument: + +```typescript +const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'greet' }, + argument: { name: 'name', value: 'Al' } +}); +console.log(result.completion.values); // ['Alice'] +``` + +### Logging -See the MCP spec sections on prompts and resources for complete details, and [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) for client‑side usage patterns. +The server can send log messages to the client using `server.sendLoggingMessage()`. Clients can request a minimum log level via the `logging/setLevel` request, which `McpServer` handles automatically — messages below the requested level are suppressed. + +```typescript +// Send a log message from a tool handler: +server.registerTool( + 'process-data', + { + description: 'Process some data', + inputSchema: { data: z.string() } + }, + async ({ data }, extra) => { + await server.sendLoggingMessage({ level: 'info', data: `Processing: ${data}` }, extra.sessionId); + // ... do work ... + return { content: [{ type: 'text', text: 'Done' }] }; + } +); +``` + +For a full example, see [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) which uses `sendLoggingMessage` throughout. + +Log levels in order: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. + +#### Log level filtering + +Clients can request a minimum log level via `logging/setLevel`. The low-level `Server` handles this automatically when the `logging` capability is enabled — it stores the requested level per session and suppresses messages below it. You can also send log messages directly using +`sendLoggingMessage`: + +```typescript +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + +// Client requests: only show 'warning' and above +// (handled automatically by the Server) + +// These will be sent or suppressed based on the client's requested level: +await server.sendLoggingMessage({ level: 'debug', data: 'verbose detail' }); // suppressed +await server.sendLoggingMessage({ level: 'warning', data: 'something is off' }); // sent +await server.sendLoggingMessage({ level: 'error', data: 'something broke' }); // sent +``` ### Display names and metadata diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index d220806d3..0ea9c1934 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -72,6 +72,29 @@ const getServer = () => { title: 'Newsletter', description: 'Subscribe to newsletter?', default: false + }, + role: { + type: 'string', + title: 'Role', + description: 'Your primary role', + oneOf: [ + { const: 'developer', title: 'Developer' }, + { const: 'designer', title: 'Designer' }, + { const: 'manager', title: 'Manager' }, + { const: 'other', title: 'Other' } + ], + default: 'developer' + }, + interests: { + type: 'array', + title: 'Interests', + description: 'Select your areas of interest', + items: { + type: 'string', + enum: ['frontend', 'backend', 'mobile', 'devops', 'ai'] + }, + minItems: 1, + maxItems: 3 } }, required: ['username', 'email', 'password'] diff --git a/src/examples/server/progressExample.ts b/src/examples/server/progressExample.ts new file mode 100644 index 000000000..da50c84eb --- /dev/null +++ b/src/examples/server/progressExample.ts @@ -0,0 +1,58 @@ +/** + * Example: Progress notifications over stdio. + * + * Demonstrates a tool that reports progress to the client while processing. + * + * Run: + * npx tsx src/examples/server/progressExample.ts + * + * Then connect a client with an `onprogress` callback (see docs/protocol.md). + */ + +import { McpServer } from '../../server/mcp.js'; +import { StdioServerTransport } from '../../server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ name: 'progress-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number().int().min(1).max(100) } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: `Cancelled at ${i}` }], isError: true }; + } + + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + + // Simulate work + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); From e79d14ab6d8fbcb49543cab3917a60a89d0a6df9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:06:14 +0000 Subject: [PATCH 20/43] fix: prevent command injection in example URL opening (v1.x backport) (#1579) --- src/examples/client/elicitationUrlExample.ts | 20 +++----------------- src/examples/client/simpleOAuthClient.ts | 20 +------------------- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts index b57927e3f..5e31fdaa7 100644 --- a/src/examples/client/elicitationUrlExample.ts +++ b/src/examples/client/elicitationUrlExample.ts @@ -25,7 +25,6 @@ import { } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { OAuthClientMetadata } from '../../shared/auth.js'; -import { exec } from 'node:child_process'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; import { UnauthorizedError } from '../../client/auth.js'; import { createServer } from 'node:http'; @@ -45,8 +44,7 @@ const clientMetadata: OAuthClientMetadata = { scope: 'mcp:tools' }; oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }); // Create readline interface for user input @@ -259,17 +257,6 @@ async function elicitationLoop(): Promise { } } -async function openBrowser(url: string): Promise { - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); -} - /** * Enqueues an elicitation request and returns the result. * @@ -402,9 +389,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - console.log(`🌐 Opening browser for authorization: ${url}`); - - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); - } /** * Example OAuth callback handler - in production, use a more robust approach * for handling callbacks and storing tokens @@ -166,9 +150,7 @@ class InteractiveOAuthClient { CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`📌 OAuth redirect handler called - opening browser`); - console.log(`Opening browser to: ${redirectUrl.toString()}`); - this.openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }, this.clientMetadataUrl ); From 09a85a80439f0ca9e5556ac20bdec41461e35234 Mon Sep 17 00:00:00 2001 From: qing-ant Date: Tue, 24 Feb 2026 13:46:12 -0800 Subject: [PATCH 21/43] fix: call onerror for silently swallowed transport errors (#1580) Co-authored-by: Felix Weinberger --- src/server/webStandardStreamableHttp.ts | 24 +++- test/server/streamableHttp.test.ts | 160 ++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 4943565a1..1f528427c 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -383,6 +383,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // The client MUST include an Accept header, listing text/event-stream as a supported content type. const acceptHeader = req.headers.get('accept'); if (!acceptHeader?.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); return this.createJsonErrorResponse(406, -32000, 'Not Acceptable: Client must accept text/event-stream'); } @@ -409,6 +410,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Check if there's already an active standalone SSE stream for this session if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { // Only one GET SSE stream is allowed per session + this.onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Only one SSE stream is allowed per session'); } @@ -460,6 +462,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { */ private async replayEvents(lastEventId: string): Promise { if (!this._eventStore) { + this.onerror?.(new Error('Event store not configured')); return this.createJsonErrorResponse(400, -32000, 'Event store not configured'); } @@ -470,11 +473,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamId = await this._eventStore.getStreamIdForEventId(lastEventId); if (!streamId) { + this.onerror?.(new Error('Invalid event ID format')); return this.createJsonErrorResponse(400, -32000, 'Invalid event ID format'); } // Check conflict with the SAME streamId we'll use for mapping if (this._streamMapping.get(streamId) !== undefined) { + this.onerror?.(new Error('Conflict: Stream already has an active connection')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Stream already has an active connection'); } } @@ -556,7 +561,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { eventData += `data: ${JSON.stringify(message)}\n\n`; controller.enqueue(encoder.encode(eventData)); return true; - } catch { + } catch (error) { + this.onerror?.(error as Error); return false; } } @@ -565,6 +571,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Handles unsupported requests (PUT, PATCH, etc.) */ private handleUnsupportedRequest(): Response { + this.onerror?.(new Error('Method not allowed.')); return new Response( JSON.stringify({ jsonrpc: '2.0', @@ -593,6 +600,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const acceptHeader = req.headers.get('accept'); // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( 406, -32000, @@ -602,6 +610,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const ct = req.headers.get('content-type'); if (!ct || !ct.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json'); } @@ -618,6 +627,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { try { rawMessage = await req.json(); } catch { + this.onerror?.(new Error('Parse error: Invalid JSON')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON'); } } @@ -632,6 +642,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } } catch { + this.onerror?.(new Error('Parse error: Invalid JSON-RPC message')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON-RPC message'); } @@ -642,9 +653,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. if (this._initialized && this.sessionId !== undefined) { + this.onerror?.(new Error('Invalid Request: Server already initialized')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Server already initialized'); } if (messages.length > 1) { + this.onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Only one initialization request is allowed'); } this.sessionId = this.sessionIdGenerator?.(); @@ -824,6 +837,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } if (!this._initialized) { // If the server has not been initialized yet, reject all requests + this.onerror?.(new Error('Bad Request: Server not initialized')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Server not initialized'); } @@ -831,11 +845,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { if (!sessionId) { // Non-initialization requests without a session ID should return 400 Bad Request + this.onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Mcp-Session-Id header is required'); } if (sessionId !== this.sessionId) { // Reject requests with invalid session ID with 404 Not Found + this.onerror?.(new Error('Session not found')); return this.createJsonErrorResponse(404, -32001, 'Session not found'); } @@ -859,6 +875,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const protocolVersion = req.headers.get('mcp-protocol-version'); if (protocolVersion !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + this.onerror?.( + new Error( + `Bad Request: Unsupported protocol version: ${protocolVersion}` + + ` (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + ) + ); return this.createJsonErrorResponse( 400, -32000, diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 3968c21a5..4a4f7d824 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -2,6 +2,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from 'node import { AddressInfo, createServer as netCreateServer } from 'node:net'; import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from '../../src/server/streamableHttp.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/webStandardStreamableHttp.js'; import { McpServer } from '../../src/server/mcp.js'; import { CallToolResult, JSONRPCMessage } from '../../src/types.js'; import { AuthInfo } from '../../src/server/auth/types.js'; @@ -3112,3 +3113,162 @@ async function createTestServerWithDnsProtection(config: { baseUrl: serverUrl }; } + +describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let onerrorSpy: ReturnType void>>; + + /** Shorthand to build a Web Standard Request for direct transport testing. */ + function req(method: string, opts?: { body?: unknown; headers?: Record }): Request { + const headers: Record = { ...opts?.headers }; + if (method === 'POST') { + headers['Accept'] ??= 'application/json, text/event-stream'; + headers['Content-Type'] ??= 'application/json'; + } else if (method === 'GET') { + headers['Accept'] ??= 'text/event-stream'; + } + return new Request('http://localhost/mcp', { + method, + headers, + body: opts?.body !== undefined ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined + }); + } + + function withSession(sessionId: string, extra?: Record): Record { + return { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', ...extra }; + } + + beforeEach(async () => { + onerrorSpy = vi.fn<(error: Error) => void>(); + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + transport.onerror = onerrorSpy; + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + onerrorSpy.mockClear(); + const response = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(response.status).toBe(200); + return response.headers.get('mcp-session-id') as string; + } + + it('should call onerror for invalid JSON in POST', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: 'not valid json' })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON/); + }); + + it('should call onerror for invalid JSON-RPC message', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('POST', { body: { not: 'valid' }, headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON-RPC message/); + }); + + it('should call onerror for missing Accept header on POST', async () => { + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for unsupported Content-Type', async () => { + await transport.handleRequest( + req('POST', { + body: TEST_MESSAGES.initialize, + headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'text/plain' } + }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported Media Type/); + }); + + it('should call onerror when server is not initialized', async () => { + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList })); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server not initialized/); + }); + + it('should call onerror for invalid session ID', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession('invalid-session-id') })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Session not found/); + }); + + it('should call onerror for re-initialization attempt', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server already initialized/); + }); + + it('should call onerror for missing Accept header on GET', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('GET', { headers: { Accept: 'application/json', ...withSession(sid) } })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for concurrent SSE streams', async () => { + const sid = await initializeServer(); + const response1 = await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(response1.status).toBe(200); + await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Only one SSE stream/); + }); + + it('should call onerror for unsupported protocol version', async () => { + const sid = await initializeServer(); + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession(sid, { 'mcp-protocol-version': 'unsupported-version' }) }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported protocol version/); + }); + + it('should call onerror for unsupported HTTP methods', async () => { + await transport.handleRequest(req('PUT')); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Method not allowed/); + }); + + it('should call onerror for invalid event ID in replay', async () => { + const eventStore: EventStore = { + async storeEvent(): Promise { + return 'evt-1'; + }, + async getStreamIdForEventId(): Promise { + return undefined; + }, + async replayEventsAfter(): Promise { + return 'stream-1'; + } + }; + const storeTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore }); + const storeSpy = vi.fn<(error: Error) => void>(); + storeTransport.onerror = storeSpy; + await new McpServer({ name: 'test', version: '1.0.0' }).connect(storeTransport); + + const initResp = await storeTransport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + const sid = initResp.headers.get('mcp-session-id') as string; + storeSpy.mockClear(); + + const response = await storeTransport.handleRequest( + req('GET', { headers: { ...withSession(sid), 'Last-Event-ID': 'unknown-event-id' } }) + ); + expect(response.status).toBe(400); + expect(storeSpy).toHaveBeenCalledTimes(1); + expect(storeSpy.mock.calls[0]![0]!.message).toMatch(/Invalid event ID format/); + await storeTransport.close(); + }); +}); From 4faa8c899c069a98f8a0c3f804ec1a50dc2bae64 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:53:52 +0000 Subject: [PATCH 22/43] chore: bump version to 1.27.1 (#1581) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54ef65919..ed383fcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index e81f5f88e..5d6c68e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 351e1246fc88c6bb97e909be9190a68c6e05bf1a Mon Sep 17 00:00:00 2001 From: Den Delimarsky Date: Wed, 25 Feb 2026 19:13:29 +0000 Subject: [PATCH 23/43] docs: add links to hosted V1 and V2 API reference docs :house: Remote-Dev: homespace --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7a553ebbd..2d2f19ae3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ For more details on how to run these examples (including recommended commands an - [docs/protocol.md](docs/protocol.md) – protocol features: ping, progress, cancellation, pagination, capability negotiation, and JSON Schema. - [docs/faq.md](docs/faq.md) – environment and troubleshooting FAQs (including Node.js Web Crypto support). - External references: + - [V1 API reference](https://modelcontextprotocol.github.io/typescript-sdk/) + - [V2 API reference](https://modelcontextprotocol.github.io/typescript-sdk/v2/) - [Model Context Protocol documentation](https://modelcontextprotocol.io) - [MCP Specification](https://spec.modelcontextprotocol.io) - [Example Servers](https://github.com/modelcontextprotocol/servers) From c9b58d19861b3917f23f5cda395c71f8239b8b33 Mon Sep 17 00:00:00 2001 From: Anthony Giniers Date: Mon, 2 Mar 2026 12:42:39 +0100 Subject: [PATCH 24/43] feat: use scopes_supported from resource metadata by default (fixes #580) (#757) Co-authored-by: Paul Carleton --- src/client/auth.ts | 21 +++++++++++++++++-- test/client/auth.test.ts | 45 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index b59862052..eee0ba855 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -501,6 +501,13 @@ async function authInternal( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + // Apply scope selection strategy (SEP-835): + // 1. WWW-Authenticate scope (passed via `scope` param) + // 2. PRM scopes_supported + // 3. Client metadata scope (user-configured fallback) + // The resolved scope is used consistently for both DCR and the authorization request. + const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; + // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -534,6 +541,7 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + scope: resolvedScope, fetchFn }); @@ -594,7 +602,7 @@ async function authInternal( clientInformation, state, redirectUrl: provider.redirectUrl, - scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope, + scope: resolvedScope, resource }); @@ -1416,16 +1424,22 @@ export async function fetchToken( /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. + * + * If `scope` is provided, it overrides `clientMetadata.scope` in the registration + * request body. This allows callers to apply the Scope Selection Strategy (SEP-835) + * consistently across both DCR and the subsequent authorization request. */ export async function registerClient( authorizationServerUrl: string | URL, { metadata, clientMetadata, + scope, fetchFn }: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; + scope?: string; fetchFn?: FetchLike; } ): Promise { @@ -1446,7 +1460,10 @@ export async function registerClient( headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(clientMetadata) + body: JSON.stringify({ + ...clientMetadata, + ...(scope !== undefined ? { scope } : {}) + }) }); if (!response.ok) { diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 8df325eed..502cc15b9 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -17,7 +17,7 @@ import { } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; -import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js'; +import { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '../../src/shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; // Mock pkce-challenge @@ -1873,6 +1873,43 @@ describe('OAuth Authorization', () => { ); }); + it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => { + const clientMetadataWithScope: OAuthClientMetadata = { + ...validClientMetadata, + scope: 'should-be-overridden' + }; + + const expectedClientInfo = { + ...validClientInfo, + scope: 'openid profile' + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedClientInfo + }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: clientMetadataWithScope, + scope: 'openid profile' + }); + + expect(clientInfo).toEqual(expectedClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/register' + }), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' }) + }) + ); + }); + it('validates client information response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -2746,6 +2783,12 @@ describe('OAuth Authorization', () => { const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; const authUrl: URL = redirectCall[0]; expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin'); + + // Verify the same scope was also used in the DCR request body + const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register')); + expect(registerCall).toBeDefined(); + const registerBody = JSON.parse(registerCall![1].body); + expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin'); }); it('prefers explicit scope parameter over scopes_supported from PRM', async () => { From 4cbcec0edb96332d87ed42c1910dca92cca507de Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 2 Mar 2026 11:46:30 +0000 Subject: [PATCH 25/43] [v1.x backport] Default to client_secret_basic when server omits token_endpoint_auth_methods_supported (#1611) Co-authored-by: Basil Hosmer Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> --- src/client/auth.ts | 18 +++++++----- test/client/auth.test.ts | 59 ++++++++++++++++++++++++++++++---------- test/client/sse.test.ts | 29 ++++++++++++++++---- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index eee0ba855..85398340b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -263,21 +263,25 @@ const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod { const hasClientSecret = clientInformation.client_secret !== undefined; - // If server doesn't specify supported methods, use RFC 6749 defaults - if (supportedMethods.length === 0) { - return hasClientSecret ? 'client_secret_post' : 'none'; - } - - // Prefer the method returned by the server during client registration if valid and supported + // Prefer the method returned by the server during client registration, if valid. + // When server metadata is present we also require the method to be listed as supported; + // when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone. if ( 'token_endpoint_auth_method' in clientInformation && clientInformation.token_endpoint_auth_method && isClientAuthMethod(clientInformation.token_endpoint_auth_method) && - supportedMethods.includes(clientInformation.token_endpoint_auth_method) + (supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method)) ) { return clientInformation.token_endpoint_auth_method; } + // If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the + // default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP + // Basic authentication for clients with a secret, making it the safest default. + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_basic' : 'none'; + } + // Try methods in priority order (most secure first) if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { return 'client_secret_basic'; diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 502cc15b9..6b70fbe94 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -1297,6 +1297,27 @@ describe('OAuth Authorization', () => { const authMethod = selectClientAuthMethod(clientInfo, supportedMethods); expect(authMethod).toBe('none'); }); + it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => { + // RFC 8414 §2: if omitted, the default is client_secret_basic. + // RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret. + const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_basic'); + }); + it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => { + const clientInfo = { client_id: 'test-client-id' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('none'); + }); + it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => { + const clientInfo = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_endpoint_auth_method: 'client_secret_post' + }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_post'); + }); }); describe('startAuthorization', () => { @@ -1513,8 +1534,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1552,8 +1575,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1675,8 +1700,10 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('authorization_code'); expect(body.get('code')).toBe('code123'); expect(body.get('code_verifier')).toBe('verifier123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -1735,8 +1762,10 @@ describe('OAuth Authorization', () => { const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); @@ -3133,7 +3162,7 @@ describe('OAuth Authorization', () => { expect(body.get('client_secret')).toBeNull(); }); - it('defaults to client_secret_post when no auth methods specified', async () => { + it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -3150,13 +3179,15 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual(validTokens); const request = mockFetch.mock.calls[0][1]; - // Check headers - expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); - expect(request.headers.get('Authorization')).toBeNull(); + // RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted, + // the default is client_secret_basic (HTTP Basic auth, not body params) + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); const body = request.body as URLSearchParams; - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); }); }); diff --git a/test/client/sse.test.ts b/test/client/sse.test.ts index 6574b60b8..92ca5a883 100644 --- a/test/client/sse.test.ts +++ b/test/client/sse.test.ts @@ -8,6 +8,21 @@ import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; import { listenOnRandomPort } from '../helpers/http.js'; import { AddressInfo } from 'node:net'; +/** + * Parses HTTP Basic auth from a request's Authorization header. + * Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed. + * client_secret_basic is the default client auth method when server metadata omits + * token_endpoint_auth_methods_supported (RFC 8414 §2). + */ +function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Basic ')) return undefined; + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + if (sep === -1) return undefined; + return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) }; +} + describe('SSEClientTransport', () => { let resourceServer: Server; let authServer: Server; @@ -668,11 +683,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -796,11 +812,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === 'test-client-secret' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -1230,10 +1247,12 @@ describe('SSEClientTransport', () => { }); req.on('end', () => { const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'authorization_code' && params.get('code') === 'test-auth-code' && - params.get('client_id') === 'test-client-id' + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( From 93640d33ab2725c8fe2cfad240f38f27a624194e Mon Sep 17 00:00:00 2001 From: Tilak Dave <72644042+tiluckdave@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:15:17 +0530 Subject: [PATCH 26/43] fix: reject plain JSON Schema objects passed as inputSchema (#1596) Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> --- src/server/mcp.ts | 13 +++++++++---- test/server/mcp.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7e61b4364..9fe0ed549 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1024,9 +1024,10 @@ export class McpServer { annotations = rest.shift() as ToolAnnotations; } } else if (typeof firstArg === 'object' && firstArg !== null) { - // Not a ZodRawShapeCompat, so must be annotations in this position - // Case: tool(name, annotations, cb) - // Or: tool(name, description, annotations, cb) + // ToolAnnotations values are primitives. Nested objects indicate a misplaced schema + if (Object.values(firstArg).some(v => typeof v === 'object' && v !== null)) { + throw new Error(`Tool ${name} expected a Zod schema or ToolAnnotations, but received an unrecognized object`); + } annotations = rest.shift() as ToolAnnotations; } } @@ -1386,7 +1387,7 @@ function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { /** * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, - * otherwise returns the schema as is. + * otherwise returns the schema as is. Throws if the value is not a valid Zod schema. */ function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): AnySchema | undefined { if (!schema) { @@ -1397,6 +1398,10 @@ function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): return objectFromShape(schema); } + if (!isZodSchemaInstance(schema as object)) { + throw new Error('inputSchema must be a Zod schema or raw shape, received an unrecognized object'); + } + return schema; } diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index f6c2124e1..961af234a 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -2050,6 +2050,37 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Clean up spies warnSpy.mockRestore(); }); + + test('should reject plain JSON Schema objects passed as inputSchema', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const jsonSchema = { + type: 'object', + properties: { + directory_id: { + type: 'string', + format: 'uuid', + description: 'The UUID of the directory' + } + }, + required: ['directory_id'] + } as any; + + const cb = async ({ directory_id }: any) => ({ + content: [{ type: 'text' as const, text: `Got: ${directory_id}` }] + }); + + expect(() => { + mcpServer.tool('test', 'A tool', jsonSchema, cb); + }).toThrow(/unrecognized object/); + + expect(() => { + mcpServer.registerTool('test', { description: 'A tool', inputSchema: jsonSchema }, cb); + }).toThrow(/unrecognized object/); + }); }); describe('resource()', () => { From 398dc70f64a8bf42cc1da2ce2d2479bbde933fc3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 23 Mar 2026 16:39:42 +0000 Subject: [PATCH 27/43] fix: clear _timeoutInfo in _onclose() and scope .finally() abort controller cleanup (#1462) Co-authored-by: Felix Weinberger --- CLAUDE.md | 2 +- src/shared/protocol.ts | 11 ++- test/shared/protocol.test.ts | 159 +++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e768e559..2cbb850df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ npm run typecheck # Type-check without emitting - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix - **Imports**: ES module style, include `.js` extension, group imports logically - **Formatting**: 2-space indentation, semicolons required, single quotes preferred -- **Testing**: Co-locate tests with source files, use descriptive test names +- **Testing**: Co-locate tests with source files, use descriptive test names. Use `vi.useFakeTimers()` instead of real `setTimeout`/`await` delays in tests - **Comments**: JSDoc for public APIs, inline comments for complex logic ## Architecture Overview diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5bb6d62ed..4ebfb5eff 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -648,6 +648,11 @@ export abstract class Protocol this._onerror(new Error(`Failed to send response: ${error}`))) .finally(() => { - this._requestHandlerAbortControllers.delete(request.id); + // Only delete if the stored controller is still ours; after close()+connect(), + // a new connection may have reused the same request ID with a different controller. + if (this._requestHandlerAbortControllers.get(request.id) === abortController) { + this._requestHandlerAbortControllers.delete(request.id); + } }); } diff --git a/test/shared/protocol.test.ts b/test/shared/protocol.test.ts index 886dcbb21..733146f29 100644 --- a/test/shared/protocol.test.ts +++ b/test/shared/protocol.test.ts @@ -5556,3 +5556,162 @@ describe('Error handling for missing resolvers', () => { }); }); }); + +describe('_onclose cleanup', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + vi.useFakeTimers(); + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('should clear pending timeouts in _onclose to prevent spurious cancellation after reconnect', async () => { + await protocol.connect(transport); + + // Start a request with a long timeout + const request = { method: 'example', params: {} }; + const mockSchema = z.object({ result: z.string() }); + + const requestPromise = protocol + .request(request, mockSchema, { + timeout: 60000 + }) + .catch(() => { + /* expected ConnectionClosed error */ + }); + + // Verify the request was sent + expect(sendSpy).toHaveBeenCalled(); + + // Spy on clearTimeout to verify it gets called during close + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + // Close the transport - this should clear timeouts + await transport.close(); + + // Verify clearTimeout was called (at least once for our timeout) + expect(clearTimeoutSpy).toHaveBeenCalled(); + + // Now reconnect with a new transport + const transport2 = new MockTransport(); + const sendSpy2 = vi.spyOn(transport2, 'send'); + await protocol.connect(transport2); + + // Advance past the original timeout - if not cleared, this would fire the callback + await vi.advanceTimersByTimeAsync(60000); + + // Verify no spurious cancellation notification was sent to the new transport + const cancellationCalls = sendSpy2.mock.calls.filter(call => { + const msg = call[0] as Record; + return msg.method === 'notifications/cancelled'; + }); + expect(cancellationCalls).toHaveLength(0); + + await transport2.close(); + await requestPromise; + clearTimeoutSpy.mockRestore(); + }); + + test('should not let stale .finally() delete a new connections abort controller after reconnect', async () => { + await protocol.connect(transport); + + const TestRequestSchema = z.object({ + method: z.literal('test/longRunning'), + params: z.optional(z.record(z.unknown())) + }); + + // Set up a handler with a deferred resolution we control + let resolveHandler!: () => void; + const handlerStarted = new Promise(resolve => { + protocol.setRequestHandler(TestRequestSchema, async () => { + resolve(); // signal that handler has started + // Wait for explicit resolution + await new Promise(r => { + resolveHandler = r; + }); + return { _meta: {} } as Result; + }); + }); + + // Simulate an incoming request with id=1 on the first connection + const requestId = 1; + transport.onmessage!({ + jsonrpc: '2.0', + id: requestId, + method: 'test/longRunning', + params: {} + }); + + // Wait for handler to start + await handlerStarted; + + // Close the connection (aborts the controller and clears the map) + await transport.close(); + + // Reconnect with a new transport + const transport2 = new MockTransport(); + await protocol.connect(transport2); + + // Set up a new handler for the second connection that we can verify cancellation on + let wasAborted = false; + let resolveHandler2!: () => void; + const handler2Started = new Promise(resolve => { + protocol.setRequestHandler(TestRequestSchema, async (_request, extra) => { + resolve(); + await new Promise(r => { + resolveHandler2 = r; + }); + wasAborted = extra.signal.aborted; + return { _meta: {} } as Result; + }); + }); + + // Simulate same request id=1 on the new connection + transport2.onmessage!({ + jsonrpc: '2.0', + id: requestId, + method: 'test/longRunning', + params: {} + }); + + await handler2Started; + + // Resolve the OLD handler so its .finally() runs + resolveHandler(); + // Flush microtasks so .finally() executes + await vi.advanceTimersByTimeAsync(0); + + // Send cancellation for request id=1 on the new connection. + // If the old .finally() incorrectly deleted the new controller, this won't work. + transport2.onmessage!({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: requestId, + reason: 'test cancel' + } + }); + + // Resolve handler2 so it can check the abort signal + resolveHandler2(); + await vi.advanceTimersByTimeAsync(0); + + expect(wasAborted).toBe(true); + + await transport2.close(); + }); +}); From 897bc25bfe7beb782dbe31e466c8d161224cfed7 Mon Sep 17 00:00:00 2001 From: Alice T'Poteat Date: Wed, 25 Mar 2026 03:47:45 -0700 Subject: [PATCH 28/43] fix(server/auth): RFC 8252 loopback port relaxation (#1738) --- .../rfc8252-loopback-port-relaxation.md | 6 + src/server/auth/handlers/authorize.ts | 36 +++++- test/server/auth/handlers/authorize.test.ts | 108 +++++++++++++++++- 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 .changeset/rfc8252-loopback-port-relaxation.md diff --git a/.changeset/rfc8252-loopback-port-relaxation.md b/.changeset/rfc8252-loopback-port-relaxation.md new file mode 100644 index 000000000..83ce27aa6 --- /dev/null +++ b/.changeset/rfc8252-loopback-port-relaxation.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native +clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991). diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index dcb6c03ec..4b9f3b327 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -15,6 +15,39 @@ export type AuthorizationHandlerOptions = { rateLimit?: Partial | false; }; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Validates a requested redirect_uri against a registered one. + * + * Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST + * allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to + * accommodate native clients that obtain an ephemeral port from the OS. For + * non-loopback URIs, exact match is required. + * + * @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + */ +export function redirectUriMatches(requested: string, registered: string): boolean { + if (requested === registered) { + return true; + } + let req: URL, reg: URL; + try { + req = new URL(requested); + reg = new URL(registered); + } catch { + return false; + } + // Port relaxation only applies when both URIs target a loopback host. + if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) { + return false; + } + // RFC 8252 relaxes the port only — scheme, host, path, and query must + // still match exactly. Note: hostname must match exactly too (the RFC + // does not allow localhost↔127.0.0.1 cross-matching). + return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search; +} + // Parameters that must be validated in order to issue redirects. const ClientAuthorizationParamsSchema = z.object({ client_id: z.string(), @@ -78,7 +111,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A } if (redirect_uri !== undefined) { - if (!client.redirect_uris.includes(redirect_uri)) { + const requested = redirect_uri; + if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) { throw new InvalidRequestError('Unregistered redirect_uri'); } } else if (client.redirect_uris.length === 1) { diff --git a/test/server/auth/handlers/authorize.test.ts b/test/server/auth/handlers/authorize.test.ts index 0f831ae7d..f4d68d4df 100644 --- a/test/server/auth/handlers/authorize.test.ts +++ b/test/server/auth/handlers/authorize.test.ts @@ -1,4 +1,4 @@ -import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js'; +import { authorizationHandler, AuthorizationHandlerOptions, redirectUriMatches } from '../../../../src/server/auth/handlers/authorize.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js'; @@ -23,6 +23,14 @@ describe('Authorization Handler', () => { scope: 'profile email' }; + // Native app client with a portless loopback redirect (e.g., from CIMD / SEP-991) + const loopbackClient: OAuthClientInformationFull = { + client_id: 'loopback-client', + client_secret: 'valid-secret', + redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'], + scope: 'profile email' + }; + // Mock client store const mockClientStore: OAuthRegisteredClientsStore = { async getClient(clientId: string): Promise { @@ -30,6 +38,8 @@ describe('Authorization Handler', () => { return validClient; } else if (clientId === 'multi-redirect-client') { return multiRedirectClient; + } else if (clientId === 'loopback-client') { + return loopbackClient; } return undefined; } @@ -172,6 +182,102 @@ describe('Authorization Handler', () => { const location = new URL(response.header.location); expect(location.origin + location.pathname).toBe('https://example.com/callback'); }); + + // RFC 8252 §7.3: authorization servers MUST allow any port for loopback + // redirect URIs. Native apps obtain ephemeral ports from the OS. + it('accepts loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.hostname).toBe('localhost'); + expect(location.port).toBe('53428'); + expect(location.pathname).toBe('/callback'); + }); + + it('accepts 127.0.0.1 loopback redirect_uri with ephemeral port (RFC 8252)', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://127.0.0.1:9000/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + }); + + it('rejects loopback redirect_uri with different path', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'loopback-client', + redirect_uri: 'http://localhost:53428/evil', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + + it('does not relax port for non-loopback redirect_uri', async () => { + const response = await supertest(app).get('/authorize').query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com:8443/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + }); + }); + + describe('redirectUriMatches (RFC 8252 §7.3)', () => { + it('exact match passes', () => { + expect(redirectUriMatches('https://example.com/cb', 'https://example.com/cb')).toBe(true); + }); + + it('loopback: any port matches portless registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost/callback')).toBe(true); + expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1/callback')).toBe(true); + expect(redirectUriMatches('http://[::1]:9000/cb', 'http://[::1]/cb')).toBe(true); + }); + + it('loopback: any port matches ported registration', () => { + expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost:3118/callback')).toBe(true); + }); + + it('loopback: different path rejected', () => { + expect(redirectUriMatches('http://localhost:53428/evil', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: different scheme rejected', () => { + expect(redirectUriMatches('https://localhost:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('loopback: localhost↔127.0.0.1 cross-match rejected', () => { + // RFC 8252 relaxes port only, not host + expect(redirectUriMatches('http://127.0.0.1:53428/callback', 'http://localhost/callback')).toBe(false); + }); + + it('non-loopback: port must match exactly', () => { + expect(redirectUriMatches('https://example.com:8443/cb', 'https://example.com/cb')).toBe(false); + }); + + it('non-loopback: no relaxation for private IPs', () => { + expect(redirectUriMatches('http://192.168.1.1:8080/cb', 'http://192.168.1.1/cb')).toBe(false); + }); + + it('malformed URIs rejected', () => { + expect(redirectUriMatches('not a url', 'http://localhost/cb')).toBe(false); + expect(redirectUriMatches('http://localhost/cb', 'not a url')).toBe(false); + }); }); describe('Authorization request validation', () => { From a0565695218544fc53e99bf5b544a887d373cefa Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:36:50 +0000 Subject: [PATCH 29/43] chore: bump version to 1.28.0 (#1746) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed383fcd7..5591c45ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.1", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.27.1", + "version": "1.28.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index 5d6c68e28..9efe20dd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.1", + "version": "1.28.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 13e30f1d36de8442417fec695983bdb155c00768 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:58:25 +0000 Subject: [PATCH 30/43] fix: treat v1.x as primary branch for npm latest tag (backport #1577) (#1749) --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b92f67d81..1f64aab3e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -70,8 +70,8 @@ jobs: # Check if this is a beta release if [[ "$VERSION" == *"-beta"* ]]; then echo "tag=--tag beta" >> $GITHUB_OUTPUT - # Check if this release is from a non-main branch (patch/maintenance release) - elif [[ "${{ github.event.release.target_commitish }}" != "main" ]]; then + # Check if this release is from a non-primary branch (patch/maintenance release) + elif [[ "${{ github.event.release.target_commitish }}" != "main" && "${{ github.event.release.target_commitish }}" != "v1.x" ]]; then # Use "release-X.Y" as tag for old branch releases (e.g., "release-1.23" for 1.23.x) # npm tags are mutable pointers to versions (like "latest" pointing to 1.24.3). # Using "release-1.23" means users can `npm install @modelcontextprotocol/sdk@release-1.23` From 2a158513028d9f862c4188b6957e78cd5663f26b Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Wed, 25 Mar 2026 08:57:53 -0700 Subject: [PATCH 31/43] [v1.x] fix: disallow null (infinite) requested TTL (#1339) Co-authored-by: Konstantin Konstantinov --- src/shared/protocol.ts | 2 +- src/types.ts | 5 ++-- .../tasks/stores/in-memory.test.ts | 11 ++++---- test/experimental/tasks/task.test.ts | 28 +++++++++++++++++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4ebfb5eff..2637be65b 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -265,7 +265,7 @@ export type RequestHandlerExtra { expect(task).toBeNull(); }); - it('should support null TTL for unlimited lifetime', async () => { - // Test that null TTL means unlimited lifetime - const taskParams: TaskCreationParams = { - ttl: null - }; + it('should support omitted TTL for unlimited lifetime', async () => { + // Test that omitting TTL means unlimited lifetime (server returns null) + // Per spec: clients omit ttl to let server decide, server returns null for unlimited + const taskParams: TaskCreationParams = {}; const createdTask = await store.createTask(taskParams, 2222, { method: 'tools/call', params: {} }); - // The returned task should have null TTL + // The returned task should have null TTL (unlimited) expect(createdTask.ttl).toBeNull(); // Task should not be cleaned up even after a long time diff --git a/test/experimental/tasks/task.test.ts b/test/experimental/tasks/task.test.ts index 37e3938d2..de613a325 100644 --- a/test/experimental/tasks/task.test.ts +++ b/test/experimental/tasks/task.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { isTerminal } from '../../../src/experimental/tasks/interfaces.js'; import type { Task } from '../../../src/types.js'; +import { TaskCreationParamsSchema } from '../../../src/types.js'; describe('Task utility functions', () => { describe('isTerminal', () => { @@ -115,3 +116,30 @@ describe('Task Schema Validation', () => { }); }); }); + +describe('TaskCreationParams Schema Validation', () => { + it('should accept ttl as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60000 }); + expect(result.success).toBe(true); + }); + + it('should accept missing ttl (optional)', () => { + const result = TaskCreationParamsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('should reject null ttl (not allowed in request, only response)', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: null }); + expect(result.success).toBe(false); + }); + + it('should accept pollInterval as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ pollInterval: 1000 }); + expect(result.success).toBe(true); + }); + + it('should accept both ttl and pollInterval', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60000, pollInterval: 1000 }); + expect(result.success).toBe(true); + }); +}); From ddadaa6cc633fb5db0c094bf031b15b68a357820 Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Thu, 26 Mar 2026 09:10:55 -0700 Subject: [PATCH 32/43] [v1.x] fix: add missing size field to ResourceSchema (#1575) Co-authored-by: Claude Opus 4.6 Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/add-resource-size-field.md | 5 +++++ src/types.ts | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/add-resource-size-field.md diff --git a/.changeset/add-resource-size-field.md b/.changeset/add-resource-size-field.md new file mode 100644 index 000000000..92064689b --- /dev/null +++ b/.changeset/add-resource-size-field.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add missing `size` field to `ResourceSchema` to match the MCP specification diff --git a/src/types.ts b/src/types.ts index 173147a48..87899a38f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -900,6 +900,13 @@ export const ResourceSchema = z.object({ */ mimeType: z.optional(z.string()), + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size: z.optional(z.number()), + /** * Optional annotations for the client. */ From c95cc0943b045517e4cc414baf1f168b216c3142 Mon Sep 17 00:00:00 2001 From: Thomas Draier Date: Thu, 26 Mar 2026 21:36:24 +0100 Subject: [PATCH 33/43] Add typings exports (#1623) --- .changeset/add-types-export-condition.md | 5 +++++ package.json | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 .changeset/add-types-export-condition.md diff --git a/.changeset/add-types-export-condition.md b/.changeset/add-types-export-condition.md new file mode 100644 index 000000000..3ea969ea1 --- /dev/null +++ b/.changeset/add-types-export-condition.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add `"types"` condition to `exports` map for subpath imports (`.`, `./client`, `./server`, `./*`), enabling TypeScript to resolve type declarations with `moduleResolution: "bundler"` or `"node16"` without requiring manual `tsconfig.json` `paths` workarounds. diff --git a/package.json b/package.json index 9efe20dd7..77897d7ea 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,17 @@ ], "exports": { ".": { + "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" }, "./client": { + "types": "./dist/esm/client/index.d.ts", "import": "./dist/esm/client/index.js", "require": "./dist/cjs/client/index.js" }, "./server": { + "types": "./dist/esm/server/index.d.ts", "import": "./dist/esm/server/index.js", "require": "./dist/cjs/server/index.js" }, @@ -52,6 +55,7 @@ "require": "./dist/cjs/experimental/tasks/index.js" }, "./*": { + "types": "./dist/esm/*.d.ts", "import": "./dist/esm/*", "require": "./dist/cjs/*" } From 364f38ca2d8895aed7c37b7a0a1031bb7ae4841c Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 27 Mar 2026 14:17:12 +0200 Subject: [PATCH 34/43] v1.x npm audit fix (#1780) --- package-lock.json | 429 ++++++++++++++++++++++++++-------------------- 1 file changed, 244 insertions(+), 185 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5591c45ff..1fafec1cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -606,9 +606,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -667,9 +667,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -737,9 +737,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.14.tgz", - "integrity": "sha512-CNl/7d+yHfXExPDUsNG/kO4t2iLamqLzvsFxscTT3pbP4utbnDvc6lfvLjM3TLrjupY4Iq5FURmTzhsCstA3sw==", + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.15.tgz", + "integrity": "sha512-B1eNYpv5kas9YFC40su7SWhKHR2DwYzJRpiX6dfWzDWMcDr71myKVj//PwyqUHC7oucs3EJqcqnwvSuUwvenoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -758,9 +758,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", "dev": true, "license": "MIT", "dependencies": { @@ -988,9 +988,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -1002,9 +1002,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -1016,9 +1016,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -1030,9 +1030,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -1044,9 +1044,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -1058,9 +1058,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -1072,13 +1072,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1086,13 +1089,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1100,13 +1106,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1114,13 +1123,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1128,13 +1140,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1142,13 +1174,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1156,13 +1208,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1170,13 +1225,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1184,13 +1242,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1198,13 +1259,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1212,23 +1276,40 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1240,9 +1321,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1254,9 +1335,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -1268,9 +1349,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -1282,9 +1363,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1706,9 +1787,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1716,13 +1797,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2011,9 +2092,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2103,9 +2184,9 @@ "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -2114,7 +2195,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -2127,9 +2208,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2731,9 +2812,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2909,12 +2990,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -3046,10 +3127,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/form-data": { "version": "4.0.4", @@ -3304,9 +3386,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3389,9 +3471,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -3614,9 +3696,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3815,6 +3897,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -3968,9 +4063,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3984,28 +4079,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -4359,19 +4457,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -4426,19 +4511,6 @@ "typescript": ">=4.0.0" } }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -4523,9 +4595,9 @@ } }, "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", "dev": true, "license": "MIT", "engines": { @@ -4695,19 +4767,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/vite": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", @@ -4850,9 +4909,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { From 7213816788e634ffb9d09affe50f0295093bfb73 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 27 Mar 2026 16:27:17 +0200 Subject: [PATCH 35/43] v1.x #1623 follow up -add missing types to package.json (#1773) --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 77897d7ea..96fb1aa91 100644 --- a/package.json +++ b/package.json @@ -35,22 +35,27 @@ "require": "./dist/cjs/server/index.js" }, "./validation": { + "types": "./dist/esm/validation/index.d.ts", "import": "./dist/esm/validation/index.js", "require": "./dist/cjs/validation/index.js" }, "./validation/ajv": { + "types": "./dist/esm/validation/ajv-provider.d.ts", "import": "./dist/esm/validation/ajv-provider.js", "require": "./dist/cjs/validation/ajv-provider.js" }, "./validation/cfworker": { + "types": "./dist/esm/validation/cfworker-provider.d.ts", "import": "./dist/esm/validation/cfworker-provider.js", "require": "./dist/cjs/validation/cfworker-provider.js" }, "./experimental": { + "types": "./dist/esm/experimental/index.d.ts", "import": "./dist/esm/experimental/index.js", "require": "./dist/cjs/experimental/index.js" }, "./experimental/tasks": { + "types": "./dist/esm/experimental/tasks/index.d.ts", "import": "./dist/esm/experimental/tasks/index.js", "require": "./dist/cjs/experimental/tasks/index.js" }, From 5608e78dd0d4ca6cd7dd03278419578f1780365a Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:43:46 -0700 Subject: [PATCH 36/43] [v1.x backport] Allow servers / clients to advertise extensions in the capability object (#1811) --- .changeset/add-extensions-capability.md | 5 +++ src/spec.types.ts | 8 ++++ src/types.ts | 12 +++++- test/server/mcp.test.ts | 54 +++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-extensions-capability.md diff --git a/.changeset/add-extensions-capability.md b/.changeset/add-extensions-capability.md new file mode 100644 index 000000000..c5d4d200f --- /dev/null +++ b/.changeset/add-extensions-capability.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': minor +--- + +Add `extensions` field to `ClientCapabilities` and `ServerCapabilities` to allow servers and clients to advertise extension support per SEP-2133 diff --git a/src/spec.types.ts b/src/spec.types.ts index 07a1cceff..2640152a2 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -383,6 +383,10 @@ export interface ClientCapabilities { }; }; }; + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions?: { [key: string]: object }; } /** @@ -461,6 +465,10 @@ export interface ServerCapabilities { }; }; }; + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions?: { [key: string]: object }; } /** diff --git a/src/types.ts b/src/types.ts index 87899a38f..835eac89f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -511,7 +511,11 @@ export const ClientCapabilitiesSchema = z.object({ /** * Present if the client supports task creation. */ - tasks: ClientTasksCapabilitySchema.optional() + tasks: ClientTasksCapabilitySchema.optional(), + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), AssertObjectSchema).optional() }); export const InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ @@ -589,7 +593,11 @@ export const ServerCapabilitiesSchema = z.object({ /** * Present if the server supports task creation. */ - tasks: ServerTasksCapabilitySchema.optional() + tasks: ServerTasksCapabilitySchema.optional(), + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), AssertObjectSchema).optional() }); /** diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index 961af234a..575d6a300 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -198,6 +198,60 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { message: 'Completed step 3 of 3' }); }); + + /*** + * Test: Extensions capability registration + */ + test('should register and advertise server extensions capability', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.server.registerCapabilities({ + extensions: { + 'io.modelcontextprotocol/test-extension': { listChanged: true } + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ listChanged: true }); + }); + + test('should advertise client extensions capability to server', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + extensions: { + 'io.modelcontextprotocol/test-extension': { streaming: true } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = mcpServer.server.getClientCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ streaming: true }); + }); }); describe('ResourceTemplate', () => { From 3913fd4443a86536155e3ebef9edd2045c372c1e Mon Sep 17 00:00:00 2001 From: jnMetaCode Date: Mon, 30 Mar 2026 18:53:42 +0800 Subject: [PATCH 37/43] fix(stdio): always set windowsHide on Windows, not just in Electron (#1640) Co-authored-by: JiangNan <1394485448@qq.com> Co-authored-by: Felix Weinberger Co-authored-by: Konstantin Konstantinov --- .changeset/fix-stdio-windows-hide.md | 5 +++ src/client/stdio.ts | 6 +--- test/client/cross-spawn.test.ts | 50 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-stdio-windows-hide.md diff --git a/.changeset/fix-stdio-windows-hide.md b/.changeset/fix-stdio-windows-hide.md new file mode 100644 index 000000000..250e0a45d --- /dev/null +++ b/.changeset/fix-stdio-windows-hide.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Always set windowsHide to true when spawning stdio server processes on Windows, not just in Electron environments. diff --git a/src/client/stdio.ts b/src/client/stdio.ts index e488dcd24..f6a71a30e 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -125,7 +125,7 @@ export class StdioClientTransport implements Transport { }, stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], shell: false, - windowsHide: process.platform === 'win32' && isElectron(), + windowsHide: process.platform === 'win32', cwd: this._serverParams.cwd }); @@ -257,7 +257,3 @@ export class StdioClientTransport implements Transport { }); } } - -function isElectron() { - return 'type' in process; -} diff --git a/test/client/cross-spawn.test.ts b/test/client/cross-spawn.test.ts index 26ae682fe..5a2671195 100644 --- a/test/client/cross-spawn.test.ts +++ b/test/client/cross-spawn.test.ts @@ -150,4 +150,54 @@ describe('StdioClientTransport using cross-spawn', () => { // verify message is sent correctly expect(mockProcess.stdin.write).toHaveBeenCalled(); }); + + describe('windowsHide', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform + }); + }); + + test('should set windowsHide to true on Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: true + }) + ); + }); + + test('should set windowsHide to false on non-Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: false + }) + ); + }); + }); }); From e12cbd7078db388152f6e839abdbe09ba01f3f32 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:48:57 +0100 Subject: [PATCH 38/43] chore: bump version to 1.29.0 (#1820) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fafec1cd..21e19cb40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.28.0", + "version": "1.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.28.0", + "version": "1.29.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index 96fb1aa91..22d26ca56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.28.0", + "version": "1.29.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 9edbab7a09f31a288a27df3220edbebff45dbb6c Mon Sep 17 00:00:00 2001 From: Mo Date: Tue, 31 Mar 2026 14:20:35 +0200 Subject: [PATCH 39/43] fix(server): prioritize zod issues and format them (#1503) Co-authored-by: mozmo15 Co-authored-by: Felix Weinberger --- .changeset/fix-zod-error-message-priority.md | 5 +++ src/server/zod-compat.ts | 32 ++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-zod-error-message-priority.md diff --git a/.changeset/fix-zod-error-message-priority.md b/.changeset/fix-zod-error-message-priority.md new file mode 100644 index 000000000..eac0efd2b --- /dev/null +++ b/.changeset/fix-zod-error-message-priority.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Prioritize `error.issues[].message` over `error.message` in `getParseErrorMessage` so custom Zod error messages surface correctly. In Zod v4, `error.message` is a JSON blob of all issues, not a readable string. diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index 9d25a5efc..d95ee7908 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -188,6 +188,21 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un return undefined; } +function getDotPath(path: (string | number)[]) { + if (path.length === 0) { + return 'object root'; + } + return path.reduce((acc, seg, index) => { + if (index === 0) { + return String(seg); + } + if (typeof seg === 'number') { + return `${acc}[${seg}]`; + } + return `${acc}.${seg}`; + }, ''); +} + // --- Error message extraction --- /** * Safely extracts an error message from a parse result error. @@ -195,16 +210,21 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un */ export function getParseErrorMessage(error: unknown): string { if (error && typeof error === 'object') { + // When present, prioritize zod issues and format as a message and path + if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { + return error.issues + .map((i: { message: string; path?: (string | number)[] }) => { + if (!i.path?.length) { + return i.message; + } + return `${i.message} at ${getDotPath(i.path)}`; + }) + .join('\n'); + } // Try common error structures if ('message' in error && typeof error.message === 'string') { return error.message; } - if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { - const firstIssue = error.issues[0]; - if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) { - return String(firstIssue.message); - } - } // Fallback: try to stringify the error try { return JSON.stringify(error); From bf1e022bd219f678b3865093d58595c6c8a67f1a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:32:30 +0100 Subject: [PATCH 40/43] chore(ci): switch publish to OIDC trusted publishing (#1839) --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f64aab3e..453a5f8e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -59,10 +59,12 @@ jobs: with: node-version: 24 cache: npm - registry-url: 'https://registry.npmjs.org' - run: npm ci + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@11.5.1 + - name: Determine npm tag id: npm-tag run: | @@ -85,5 +87,3 @@ jobs: fi - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From dfe91e1aad8c978459637dd32de66949c7081f16 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 21 Apr 2026 16:34:53 +0100 Subject: [PATCH 41/43] build: exclude src/examples from published dist Examples are meant to be read and run from the repo via tsx, not imported from the package. Excluding them from the prod/cjs build configs means dist/{esm,cjs}/examples/ is no longer produced and no longer ships in the npm tarball. Follow-up to #1579 / #1553. --- tsconfig.cjs.json | 2 +- tsconfig.prod.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 4b712da77..29b41d1d5 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -6,5 +6,5 @@ "outDir": "./dist/cjs" }, "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*", "src/examples/**/*"] } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 82710bd6a..7c4ab9928 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -4,5 +4,5 @@ "outDir": "./dist/esm" }, "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*", "src/examples/**/*"] } From 480a6832c9ee1340564d0f243be2c864aa63e83c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 13:17:21 +0100 Subject: [PATCH 42/43] feat(client/auth): validate RFC 9207 iss parameter to mitigate mix-up attacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `iss` argument to `finishAuth()` and validates it against the cached authorization server metadata before exchanging the code, per the RFC 9207 §2.4 decision table keyed on `authorization_response_iss_parameter_supported`. Reference implementation for SEP-2468. --- src/client/auth.ts | 64 ++++++++++++++++++++++++++++++++++++ src/client/sse.ts | 6 +++- src/client/streamableHttp.ts | 6 +++- src/shared/auth.ts | 6 ++-- test/client/auth.test.ts | 62 +++++++++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 85398340b..67bd85124 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -239,6 +239,58 @@ export class UnauthorizedError extends Error { } } +/** + * Thrown when RFC 9207 `iss` parameter validation fails. The authorization + * code MUST NOT be sent to any token endpoint after this is thrown. + */ +export class IssuerMismatchError extends UnauthorizedError { + constructor(message: string) { + super(message); + this.name = 'IssuerMismatchError'; + } +} + +/** + * Validates the `iss` parameter from an authorization response against the + * authorization server's metadata, per RFC 9207 §2.4. The four cases are + * keyed on whether the AS advertises `authorization_response_iss_parameter_supported`: + * + * | advertised | iss present | outcome | + * |---|---|---| + * | true | yes, matches | accept | + * | true | yes, mismatch | reject | + * | true | no | reject (server promised it) | + * | false/undefined | yes | reject (unexpected, possible injection) | + * | false/undefined | no | accept (server doesn't support 9207) | + * + * Comparison is simple string comparison per RFC 3986 §6.2.1 — no + * normalization of case, ports, or trailing slashes. + */ +export function validateAuthorizationResponseIssuer( + receivedIss: string | undefined, + metadata: AuthorizationServerMetadata | undefined +): void { + const supported = metadata?.authorization_response_iss_parameter_supported === true; + const expectedIssuer = metadata?.issuer; + + if (supported) { + if (receivedIss === undefined) { + throw new IssuerMismatchError( + 'Authorization server advertises authorization_response_iss_parameter_supported but no iss parameter was received' + ); + } + if (receivedIss !== expectedIssuer) { + throw new IssuerMismatchError( + `Authorization response iss "${receivedIss}" does not match expected issuer "${expectedIssuer}"` + ); + } + } else if (receivedIss !== undefined) { + throw new IssuerMismatchError( + 'Authorization server does not advertise authorization_response_iss_parameter_supported but an iss parameter was received' + ); + } +} + type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; function isClientAuthMethod(method: string): method is ClientAuthMethod { @@ -403,6 +455,13 @@ export async function auth( options: { serverUrl: string | URL; authorizationCode?: string; + /** + * The `iss` parameter from the authorization response redirect URI + * (RFC 9207). When provided alongside `authorizationCode`, it is + * validated against the authorization server metadata before the + * code is exchanged. + */ + authorizationResponseIssuer?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; @@ -430,12 +489,14 @@ async function authInternal( { serverUrl, authorizationCode, + authorizationResponseIssuer, scope, resourceMetadataUrl, fetchFn }: { serverUrl: string | URL; authorizationCode?: string; + authorizationResponseIssuer?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; @@ -559,6 +620,9 @@ async function authInternal( // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows if (authorizationCode !== undefined || nonInteractiveFlow) { + if (authorizationCode !== undefined) { + validateAuthorizationResponseIssuer(authorizationResponseIssuer, metadata); + } const tokens = await fetchToken(provider, authorizationServerUrl, { metadata, resource, diff --git a/src/client/sse.ts b/src/client/sse.ts index f0e91ff25..9f7bead1c 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -216,8 +216,11 @@ export class SSEClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The `code` parameter from the redirect URI. + * @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, iss?: string): Promise { if (!this._authProvider) { throw new UnauthorizedError('No auth provider'); } @@ -225,6 +228,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, + authorizationResponseIssuer: iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 736587973..92078f586 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -421,8 +421,11 @@ export class StreamableHTTPClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The `code` parameter from the redirect URI. + * @param iss - The `iss` parameter from the redirect URI, if present (RFC 9207). When the authorization server advertises support, omitting this will cause auth to fail. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, iss?: string): Promise { if (!this._authProvider) { throw new UnauthorizedError('No auth provider'); } @@ -430,6 +433,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, + authorizationResponseIssuer: iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/src/shared/auth.ts b/src/shared/auth.ts index c546c8608..fb794d069 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -66,7 +66,8 @@ export const OAuthMetadataSchema = z.looseObject({ introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** @@ -109,7 +110,8 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 6b70fbe94..c4b59e400 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -13,7 +13,9 @@ import { auth, type OAuthClientProvider, selectClientAuthMethod, - isHttpsUrl + isHttpsUrl, + validateAuthorizationResponseIssuer, + IssuerMismatchError } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; @@ -3682,4 +3684,62 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('validateAuthorizationResponseIssuer (RFC 9207)', () => { + const issuer = 'https://auth.example.com'; + const baseMetadata: AuthorizationServerMetadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'] + }; + + it('accepts matching iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer(issuer, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).not.toThrow(); + }); + + it('rejects mismatched iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer('https://attacker.example.com', { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + + it('rejects absent iss when server advertises support', () => { + expect(() => + validateAuthorizationResponseIssuer(undefined, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + + it('rejects unexpected iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer(issuer, baseMetadata)).toThrow(IssuerMismatchError); + }); + + it('accepts absent iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, baseMetadata)).not.toThrow(); + }); + + it('accepts absent iss when metadata is undefined', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + }); + + it('uses simple string comparison without normalization', () => { + expect(() => + validateAuthorizationResponseIssuer(`${issuer}/`, { + ...baseMetadata, + authorization_response_iss_parameter_supported: true + }) + ).toThrow(IssuerMismatchError); + }); + }); }); From 6f0bf49dd4c8c4bfeffdbb5029d3be78965db956 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 16:42:37 +0100 Subject: [PATCH 43/43] Compare iss when present without metadata advertisement (lenient row 3) Aligns with the SEP-2468 spec text: when the AS doesn't advertise authorization_response_iss_parameter_supported but iss is present, compare rather than reject. Accommodates servers that emit iss before advertising it. --- src/client/auth.ts | 22 ++++++++-------------- test/client/auth.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 67bd85124..47ae4290b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -260,7 +260,7 @@ export class IssuerMismatchError extends UnauthorizedError { * | true | yes, matches | accept | * | true | yes, mismatch | reject | * | true | no | reject (server promised it) | - * | false/undefined | yes | reject (unexpected, possible injection) | + * | false/undefined | yes | compare (lenient vs RFC 9207 — accommodates servers that emit iss before advertising) | * | false/undefined | no | accept (server doesn't support 9207) | * * Comparison is simple string comparison per RFC 3986 §6.2.1 — no @@ -273,20 +273,14 @@ export function validateAuthorizationResponseIssuer( const supported = metadata?.authorization_response_iss_parameter_supported === true; const expectedIssuer = metadata?.issuer; - if (supported) { - if (receivedIss === undefined) { - throw new IssuerMismatchError( - 'Authorization server advertises authorization_response_iss_parameter_supported but no iss parameter was received' - ); - } - if (receivedIss !== expectedIssuer) { - throw new IssuerMismatchError( - `Authorization response iss "${receivedIss}" does not match expected issuer "${expectedIssuer}"` - ); - } - } else if (receivedIss !== undefined) { + if (supported && receivedIss === undefined) { + throw new IssuerMismatchError( + 'Authorization server advertises authorization_response_iss_parameter_supported but no iss parameter was received' + ); + } + if (receivedIss !== undefined && receivedIss !== expectedIssuer) { throw new IssuerMismatchError( - 'Authorization server does not advertise authorization_response_iss_parameter_supported but an iss parameter was received' + `Authorization response iss "${receivedIss}" does not match expected issuer "${expectedIssuer}"` ); } } diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index c4b59e400..b524b7acc 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -3721,8 +3721,14 @@ describe('OAuth Authorization', () => { ).toThrow(IssuerMismatchError); }); - it('rejects unexpected iss when server does not advertise support', () => { - expect(() => validateAuthorizationResponseIssuer(issuer, baseMetadata)).toThrow(IssuerMismatchError); + it('accepts matching iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer(issuer, baseMetadata)).not.toThrow(); + }); + + it('rejects mismatched iss when server does not advertise support', () => { + expect(() => validateAuthorizationResponseIssuer('https://attacker.example.com', baseMetadata)).toThrow( + IssuerMismatchError + ); }); it('accepts absent iss when server does not advertise support', () => {