From 52476aa29726cdf193b8d50b48e63feb5e1bff77 Mon Sep 17 00:00:00 2001 From: muzea Date: Wed, 23 Apr 2025 14:41:58 +0800 Subject: [PATCH 01/44] fix: add windows env PROGRAMFILES, avoid some exe can not be found --- src/client/stdio.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index b83bf27c..6572f0c0 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -56,6 +56,7 @@ export const DEFAULT_INHERITED_ENV_VARS = "TEMP", "USERNAME", "USERPROFILE", + "PROGRAMFILES", ] : /* list inspired by the default env inheritance of sudo */ ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; From 2deffb6c194154a2fb99dfcc3e53443debfda3a6 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 23 Apr 2025 22:10:12 -0700 Subject: [PATCH 02/44] test(server): add more tests forSSEServerTransport class --- package-lock.json | 4 +- src/server/sse.test.ts | 153 ++++++++++++++++++++++++++++++++++++++++- src/server/sse.ts | 2 +- 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1165b751..3c6e2d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "license": "MIT", "dependencies": { "content-type": "^1.0.5", diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..11705fe4 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -7,6 +7,7 @@ const createMockResponse = () => { writeHead: jest.fn(), write: jest.fn().mockReturnValue(true), on: jest.fn(), + end: jest.fn(), }; res.writeHead.mockReturnThis(); res.on.mockReturnThis(); @@ -14,6 +15,36 @@ const createMockResponse = () => { return res as unknown as http.ServerResponse; }; +const createMockRequest = ({ headers = {}, body }: { headers?: Record, body?: string } = {}) => { + const mockReq = { + headers, + body: body ? body : undefined, + auth: { + token: 'test-token', + }, + on: jest.fn().mockImplementation((event, listener) => { + const mockListener = listener as unknown as (...args: unknown[]) => void; + if (event === 'data') { + mockListener(Buffer.from(body || '') as unknown as Error); + } + if (event === 'error') { + mockListener(new Error('test')); + } + if (event === 'end') { + mockListener(); + } + if (event === 'close') { + setTimeout(listener, 100); + } + return mockReq; + }), + listeners: jest.fn(), + removeListener: jest.fn(), + } as unknown as http.IncomingMessage; + + return mockReq; +}; + describe('SSEServerTransport', () => { describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { @@ -106,4 +137,124 @@ describe('SSEServerTransport', () => { ); }); }); -}); + + describe('handlePostMessage method', () => { + it('should return 500 if server has not started', async () => { + const mockReq = createMockRequest(); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + + const error = 'SSE connection not established'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .rejects.toThrow(error); + expect(mockRes.writeHead).toHaveBeenCalledWith(500); + expect(mockRes.end).toHaveBeenCalledWith(error); + }); + + it('should return 400 if content-type is not application/json', async () => { + const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onerror = jest.fn(); + const error = 'Unsupported content-type: text/plain'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .resolves.toBe(undefined); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); + expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); + }); + + it('should return 400 if message has not a valid schema', async () => { + const invalidMessage = JSON.stringify({ + // missing jsonrpc field + method: 'call', + params: [1, 2, 3], + id: 1, + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: invalidMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(transport.onmessage).not.toHaveBeenCalled(); + expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); + }); + + it('should return 202 if message has a valid schema', async () => { + const validMessage = JSON.stringify({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: validMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(202); + expect(mockRes.end).toHaveBeenCalledWith('Accepted'); + expect(transport.onmessage).toHaveBeenCalledWith({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }, { + authInfo: { + token: 'test-token', + } + }); + }); + }); + + describe('close method', () => { + it('should call onclose', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + transport.onclose = jest.fn(); + await transport.close(); + expect(transport.onclose).toHaveBeenCalled(); + }); + }); + + describe('send method', () => { + it('should call onsend', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining('event: endpoint')); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); + }); + }); +}); \ No newline at end of file diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..164780ef 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -92,7 +92,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From b71e0965202ac89f5817da15ab38837f1e1b75ea Mon Sep 17 00:00:00 2001 From: Leonid Domnitser Date: Mon, 28 Apr 2025 16:26:31 -0700 Subject: [PATCH 03/44] fix: add missing eventsource-parser dependency --- package-lock.json | 5 +++-- package.json | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1165b751..9a277c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "license": "MIT", "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/package.json b/package.json index f24053c5..9b40c2b6 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", From 59fb4845d6e49bca6b63cb12a617872682d3133a Mon Sep 17 00:00:00 2001 From: Xiaofu Huang Date: Tue, 6 May 2025 17:25:38 +0800 Subject: [PATCH 04/44] fix: Expose the MCP child process PID as an accessible property in StdioClientTransport --- src/client/stdio.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 9e35293d..4f7f5f1b 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -184,6 +184,10 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } + get pid(): number | undefined { + return this._process?.pid; + } + private processReadBuffer() { while (true) { try { From 7e134f259edbc0e0af897afc4c52154351e208d4 Mon Sep 17 00:00:00 2001 From: Xiaofu Huang Date: Tue, 6 May 2025 17:38:26 +0800 Subject: [PATCH 05/44] test: add ut of chile process pod in StdioClientTransport --- src/client/stdio.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 646f9ea5..96dd5648 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -59,3 +59,12 @@ test("should read messages", async () => { await client.close(); }); + +test("should return child process pid", async () => { + const client = new StdioClientTransport(serverParameters); + + await client.start(); + expect(client.pid).toBeDefined(); + await client.close(); + expect(client.pid).toBeUndefined(); +}); From d154914db74627bb70bbaf1842a9eb937e67a103 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 May 2025 10:13:09 +0000 Subject: [PATCH 06/44] fix: change pid return type from undefined to be null --- src/client/stdio.test.ts | 4 ++-- src/client/stdio.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 96dd5648..b2132446 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -64,7 +64,7 @@ test("should return child process pid", async () => { const client = new StdioClientTransport(serverParameters); await client.start(); - expect(client.pid).toBeDefined(); + expect(client.pid).not.toBeNull(); await client.close(); - expect(client.pid).toBeUndefined(); + expect(client.pid).toBeNull(); }); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 4f7f5f1b..af29614e 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -184,8 +184,13 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } - get pid(): number | undefined { - return this._process?.pid; + /** + * The child process pid spawned by this transport. + * + * This is only available after the transport has been started. + */ + get pid(): number | null { + return this._process?.pid ?? null; } private processReadBuffer() { From ae121a4bcca9af73a345116418e5bf13f537b512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 09:06:55 +0000 Subject: [PATCH 07/44] Bump formidable in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [formidable](https://github.com/node-formidable/formidable). Updates `formidable` from 3.5.2 to 3.5.4 - [Release notes](https://github.com/node-formidable/formidable/releases) - [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md) - [Commits](https://github.com/node-formidable/formidable/commits) --- updated-dependencies: - dependency-name: formidable dependency-version: 3.5.4 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed986a69..fe738fcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1557,6 +1557,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1592,6 +1605,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3689,16 +3712,19 @@ } }, "node_modules/formidable": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", - "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "hexoid": "^2.0.0", "once": "^1.4.0" }, + "engines": { + "node": ">=14.0.0" + }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -3953,16 +3979,6 @@ "node": ">= 0.4" } }, - "node_modules/hexoid": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", - "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", From eefdcf5bf3f9e7228966c4978dd46f09b13ac39f Mon Sep 17 00:00:00 2001 From: dhodun Date: Fri, 23 May 2025 20:01:38 -0500 Subject: [PATCH 08/44] docs: Add clarifying comments for stateless streamable HTTP endpoints Add inline comments explaining why GET and DELETE endpoints return 405 in stateless mode: - GET: SSE notifications not supported without session management - DELETE: Session termination not needed in stateless mode --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f468969b..0c954c41 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// SSE notifications not supported in stateless mode app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET MCP request'); res.writeHead(405).end(JSON.stringify({ @@ -359,6 +360,7 @@ app.get('/mcp', async (req: Request, res: Response) => { })); }); +// Session termination not needed in stateless mode app.delete('/mcp', async (req: Request, res: Response) => { console.log('Received DELETE MCP request'); res.writeHead(405).end(JSON.stringify({ From adbacc63881908031914a527b44ab820523e7f57 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:32:26 -0700 Subject: [PATCH 09/44] Add DNS rebinding protection for SSE transport --- package-lock.json | 4 +- src/server/sse.test.ts | 240 ++++++++++++++++++++++++++++++++++++++++- src/server/sse.ts | 66 +++++++++++- 3 files changed, 306 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40bad9fe..ef539382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..38ba9e59 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,12 +1,14 @@ import http from 'http'; import { jest } from '@jest/globals'; -import { SSEServerTransport } from './sse.js'; +import { SSEServerTransport } from './sse.js'; +import { AuthInfo } from './auth/types.js'; const createMockResponse = () => { const res = { writeHead: jest.fn(), write: jest.fn().mockReturnValue(true), on: jest.fn(), + end: jest.fn().mockReturnThis(), }; res.writeHead.mockReturnThis(); res.on.mockReturnThis(); @@ -14,6 +16,12 @@ const createMockResponse = () => { return res as unknown as http.ServerResponse; }; +const createMockRequest = (headers: Record = {}) => { + return { + headers, + } as unknown as http.IncomingMessage & { auth?: AuthInfo }; +}; + describe('SSEServerTransport', () => { describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { @@ -106,4 +114,234 @@ describe('SSEServerTransport', () => { ); }); }); + + describe('DNS rebinding protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000', 'example.com'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + }); + + it('should reject requests without host header when allowedHosts is configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); + }); + }); + + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + origin: 'http://evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + }); + }); + + describe('Content-Type validation', () => { + it('should accept requests with application/json content-type', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json; charset=utf-8', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with non-application/json content-type when protection is enabled', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'text/plain', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Content-Type must start with application/json, got: text/plain'); + }); + }); + + describe('disableDnsRebindingProtection option', () => { + it('should skip all validations when disableDnsRebindingProtection is true', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'evil.com', + origin: 'http://evil.com', + 'content-type': 'text/plain', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + // Should pass even with invalid headers because protection is disabled + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + // The error should be from content-type parsing, not DNS rebinding protection + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); + }); + }); + + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + }); + await transport.start(); + + // Valid host, invalid origin + const mockReq1 = createMockRequest({ + host: 'localhost:3000', + origin: 'http://evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes1 = createMockResponse(); + + await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + + // Invalid host, valid origin + const mockReq2 = createMockRequest({ + host: 'evil.com', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes2 = createMockResponse(); + + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + + // Both valid + const mockReq3 = createMockRequest({ + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes3 = createMockResponse(); + + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); + }); + }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..6ef2baef 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -9,6 +9,29 @@ import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; +/** + * Configuration options for SSEServerTransport. + */ +export interface SSEServerTransportOptions { + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Default is false. + */ + disableDnsRebindingProtection?: boolean; +} + /** * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. * @@ -17,6 +40,7 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; + private _options: SSEServerTransportOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -28,8 +52,39 @@ export class SSEServerTransport implements Transport { constructor( private _endpoint: string, private res: ServerResponse, + options?: SSEServerTransportOptions, ) { this._sessionId = randomUUID(); + this._options = options || {disableDnsRebindingProtection: true}; + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is disabled + if (this._options.disableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; } /** @@ -86,13 +141,22 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(validationError); + this.onerror?.(new Error(validationError)); + return; + } + const authInfo: AuthInfo | undefined = req.auth; let body: string | unknown; try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From ebf2535b90ce4c81553f237f49450b6ea4f9ce71 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:52:30 -0700 Subject: [PATCH 10/44] Add protections for streamable HTTP too --- README.md | 22 ++- src/server/streamableHttp.test.ts | 263 +++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 68 ++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c9e27c27..ccc627b2 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,11 @@ app.post('/mcp', async (req, res) => { onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = transport; - } + }, + // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server + // locally, make sure to set: + // disableDnsRebindingProtection: true, + // allowedHosts: ['127.0.0.1'], }); // Clean up transport when closed @@ -386,6 +390,22 @@ This stateless approach is useful for: - RESTful scenarios where each request is independent - Horizontally scaled deployments without shared session state +#### DNS Rebinding Protection + +The Streamable HTTP transport includes DNS rebinding protection to prevent security vulnerabilities. By default, this protection is **disabled** for backwards compatibility. + +**Important**: If you are running this server locally, enable DNS rebinding protection: + +```typescript +const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + disableDnsRebindingProtection: false, + + allowedHosts: ['127.0.0.1', ...], + allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] +}); +``` + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..68fe8ee7 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1293,4 +1293,265 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); -}); \ No newline at end of file +}); + +// Test DNS rebinding protection +describe("StreamableHTTPServerTransport DNS rebinding protection", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe("Host header validation", () => { + it("should accept requests with allowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed host headers", async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain("Invalid Host header:"); + }); + + it("should reject GET requests with disallowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + }, + }); + + expect(response.status).toBe(403); + }); + }); + + describe("Origin header validation", () => { + it("should accept requests with allowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3000", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toBe("Invalid Origin header: http://evil.com"); + }); + }); + + describe("disableDnsRebindingProtection option", () => { + it("should skip all validations when disableDnsRebindingProtection is true", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Host: "evil.com", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); + }); + }); + + describe("Combined validations", () => { + it("should validate both host and origin when both are configured", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + allowedOrigins: ['http://localhost:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + expect(body1.error.message).toBe("Invalid Origin header: http://evil.com"); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3001", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response2.status).toBe(200); + }); + }); +}); + +/** + * Helper to create test server with DNS rebinding protection options + */ +async function createTestServerWithDnsProtection(config: { + sessionIdGenerator: (() => string) | undefined; + allowedHosts?: string[]; + allowedOrigins?: string[]; + disableDnsRebindingProtection?: boolean; +}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + allowedHosts: config.allowedHosts, + allowedOrigins: config.allowedOrigins, + disableDnsRebindingProtection: config.disableDnsRebindingProtection, + }); + + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + if (req.method === "POST") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + const parsedBody = JSON.parse(body); + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); + }); + } else { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + } + }); + + await new Promise((resolve) => { + httpServer.listen(3001, () => resolve()); + }); + + const port = (httpServer.address() as AddressInfo).port; + const serverUrl = new URL(`http://localhost:${port}/`); + + return { + server: httpServer, + transport, + mcpServer, + baseUrl: serverUrl, + }; +} \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..8feeac55 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -61,6 +61,24 @@ export interface StreamableHTTPServerTransportOptions { * If provided, resumability will be enabled, allowing clients to reconnect and resume messages */ eventStore?: EventStore; + + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Default is true for backwards compatibility. + */ + disableDnsRebindingProtection?: boolean; } /** @@ -109,6 +127,9 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _allowedHosts?: string[]; + private _allowedOrigins?: string[]; + private _disableDnsRebindingProtection: boolean; sessionId?: string | undefined; onclose?: () => void; @@ -120,6 +141,9 @@ export class StreamableHTTPServerTransport implements Transport { this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._allowedHosts = options.allowedHosts; + this._allowedOrigins = options.allowedOrigins; + this._disableDnsRebindingProtection = options.disableDnsRebindingProtection ?? true; } /** @@ -133,10 +157,54 @@ export class StreamableHTTPServerTransport implements Transport { this._started = true; } + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is disabled + if (this._disableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._allowedHosts && this._allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._allowedOrigins && this._allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; + } + /** * Handles an incoming HTTP request, whether GET or POST */ async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: validationError + }, + id: null + })); + this.onerror?.(new Error(validationError)); + return; + } + if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "GET") { From 41c7ed09954d63151c8f96ec8af96a827c399edf Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:53:31 -0700 Subject: [PATCH 11/44] Revert package-lock change --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef539382..40bad9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.11.4", "license": "MIT", "dependencies": { "ajv": "^6.12.6", From 88c6098495522fc34d9554076998363862617df0 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:54:49 -0700 Subject: [PATCH 12/44] Clean up --- src/server/sse.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 6ef2baef..65e8c7c8 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -27,7 +27,6 @@ export interface SSEServerTransportOptions { /** * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). - * Default is false. */ disableDnsRebindingProtection?: boolean; } @@ -156,7 +155,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct.type}`); + throw new Error(`Unsupported content-type: ${ct}`); } body = parsedBody ?? await getRawBody(req, { From 970905c3131276d1f6b392f060faac91bddbf3fa Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 14:01:36 -0700 Subject: [PATCH 13/44] Fix SSE content-type error message format --- src/server/sse.test.ts | 2 +- src/server/sse.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 38ba9e59..9fb1f30c 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -264,7 +264,7 @@ describe('SSEServerTransport', () => { await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Content-Type must start with application/json, got: text/plain'); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 65e8c7c8..73c74bb3 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -155,7 +155,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From be063e4bb8e4dcad3e7698a627f6efc9d7e4c065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Garc=C3=ADa?= Date: Fri, 30 May 2025 16:05:05 +0200 Subject: [PATCH 14/44] fix: extra Headers when they are a Headers object --- src/client/streamableHttp.test.ts | 31 +++++++++++++++++++++++++++++++ src/client/streamableHttp.ts | 23 ++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index f748a2be..11dfe7d4 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -476,6 +476,37 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + it("should always send specified custom headers (Headers class)", async () => { + const requestInit = { + headers: new Headers({ + "X-Custom-Header": "CustomValue" + }) + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + ((global.fetch as jest.Mock)).mockImplementation( + async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + ); + + await transport.start(); + + await transport["_startOrAuthSse"]({}); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); + + (requestInit.headers as Headers).set("X-Custom-Header","SecondCustomValue"); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue"); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); it("should have exponential backoff with configurable maxRetries", () => { // This test verifies the maxRetries and backoff calculation directly diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bcfbb2d..2bb0fdf4 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -174,9 +174,12 @@ export class StreamableHTTPClientTransport implements Transport { headers["mcp-session-id"] = this._sessionId; } - return new Headers( - { ...headers, ...this._requestInit?.headers } - ); + const extraHeaders = this._normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders, + }); } @@ -242,6 +245,20 @@ export class StreamableHTTPClientTransport implements Transport { } + private _normalizeHeaders(headers: HeadersInit | undefined): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return { ...headers as Record }; + } + /** * Schedule a reconnection attempt with exponential backoff * From ab900839fbd450fbd86a1fb0034803a892048c29 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Fri, 30 May 2025 09:06:01 -0700 Subject: [PATCH 15/44] Invert variable to improve code readability --- README.md | 4 ++-- src/server/sse.test.ts | 12 +++++++++--- src/server/sse.ts | 11 ++++++----- src/server/streamableHttp.test.ts | 22 +++++++++++----------- src/server/streamableHttp.ts | 14 +++++++------- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ccc627b2..32037f90 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ app.post('/mcp', async (req, res) => { }, // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server // locally, make sure to set: - // disableDnsRebindingProtection: true, + // enableDnsRebindingProtection: true, // allowedHosts: ['127.0.0.1'], }); @@ -399,7 +399,7 @@ The Streamable HTTP transport includes DNS rebinding protection to prevent secur ```typescript const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, allowedHosts: ['127.0.0.1', ...], allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 9fb1f30c..aee6eaf6 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -125,6 +125,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000', 'example.com'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -144,6 +145,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -163,6 +165,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -183,6 +186,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -202,6 +206,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -268,13 +273,13 @@ describe('SSEServerTransport', () => { }); }); - describe('disableDnsRebindingProtection option', () => { - it('should skip all validations when disableDnsRebindingProtection is true', async () => { + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: true, + enableDnsRebindingProtection: false, }); await transport.start(); @@ -300,6 +305,7 @@ describe('SSEServerTransport', () => { const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); diff --git a/src/server/sse.ts b/src/server/sse.ts index 73c74bb3..bd5d80b9 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -26,9 +26,10 @@ export interface SSEServerTransportOptions { allowedOrigins?: string[]; /** - * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. */ - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; } /** @@ -54,7 +55,7 @@ export class SSEServerTransport implements Transport { options?: SSEServerTransportOptions, ) { this._sessionId = randomUUID(); - this._options = options || {disableDnsRebindingProtection: true}; + this._options = options || {enableDnsRebindingProtection: false}; } /** @@ -62,8 +63,8 @@ export class SSEServerTransport implements Transport { * @returns Error message if validation fails, undefined if validation passes. */ private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is disabled - if (this._options.disableDnsRebindingProtection) { + // Skip validation if protection is not enabled + if (!this._options.enableDnsRebindingProtection) { return undefined; } diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 68fe8ee7..4683024b 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1312,7 +1312,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1338,7 +1338,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['example.com:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1362,7 +1362,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['example.com:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1384,7 +1384,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedOrigins: ['http://localhost:3000', 'https://example.com'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1407,7 +1407,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1429,13 +1429,13 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { }); }); - describe("disableDnsRebindingProtection option", () => { - it("should skip all validations when disableDnsRebindingProtection is true", async () => { + describe("enableDnsRebindingProtection option", () => { + it("should skip all validations when enableDnsRebindingProtection is false", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: true, + enableDnsRebindingProtection: false, }); server = result.server; transport = result.transport; @@ -1463,7 +1463,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], allowedOrigins: ['http://localhost:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1507,7 +1507,7 @@ async function createTestServerWithDnsProtection(config: { sessionIdGenerator: (() => string) | undefined; allowedHosts?: string[]; allowedOrigins?: string[]; - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; }): Promise<{ server: Server; transport: StreamableHTTPServerTransport; @@ -1523,7 +1523,7 @@ async function createTestServerWithDnsProtection(config: { sessionIdGenerator: config.sessionIdGenerator, allowedHosts: config.allowedHosts, allowedOrigins: config.allowedOrigins, - disableDnsRebindingProtection: config.disableDnsRebindingProtection, + enableDnsRebindingProtection: config.enableDnsRebindingProtection, }); await mcpServer.connect(transport); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 8feeac55..084147dc 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -75,10 +75,10 @@ export interface StreamableHTTPServerTransportOptions { allowedOrigins?: string[]; /** - * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). - * Default is true for backwards compatibility. + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. */ - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; } /** @@ -129,7 +129,7 @@ export class StreamableHTTPServerTransport implements Transport { private _onsessioninitialized?: (sessionId: string) => void; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; - private _disableDnsRebindingProtection: boolean; + private _enableDnsRebindingProtection: boolean; sessionId?: string | undefined; onclose?: () => void; @@ -143,7 +143,7 @@ export class StreamableHTTPServerTransport implements Transport { this._onsessioninitialized = options.onsessioninitialized; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; - this._disableDnsRebindingProtection = options.disableDnsRebindingProtection ?? true; + this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; } /** @@ -162,8 +162,8 @@ export class StreamableHTTPServerTransport implements Transport { * @returns Error message if validation fails, undefined if validation passes. */ private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is disabled - if (this._disableDnsRebindingProtection) { + // Skip validation if protection is not enabled + if (!this._enableDnsRebindingProtection) { return undefined; } From 921ce70fa3acf1cb626bc7ff5088f358727be335 Mon Sep 17 00:00:00 2001 From: sinedied Date: Mon, 9 Jun 2025 14:17:53 +0200 Subject: [PATCH 16/44] fix: missing "properties" property in empty schema --- src/server/mcp.test.ts | 1 + src/server/mcp.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..fd71443d 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -265,6 +265,7 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toEqual({ type: "object", + properties: {}, }); // Adding the tool before the connection was established means no notification was sent diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..436a948f 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1018,6 +1018,7 @@ export type RegisteredTool = { const EMPTY_OBJECT_JSON_SCHEMA = { type: "object" as const, + properties: {}, }; // Helper to check if an object is a Zod schema (ZodRawShape) From db9ba7b419bfe0e0686576c5365e8a969bd97637 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 14 Jun 2025 01:08:02 +0300 Subject: [PATCH 17/44] raw request propagation in tools - implementation, unit tests, types --- package-lock.json | 4 +- package.json | 2 +- src/client/index.test.ts | 13 +- src/server/index.test.ts | 14 +- src/server/mcp.test.ts | 367 ++++++++++++++---------------- src/server/mcp.ts | 4 + src/server/sse.test.ts | 204 ++++++++++++++++- src/server/sse.ts | 8 +- src/server/streamableHttp.test.ts | 65 ++++++ src/server/streamableHttp.ts | 8 +- src/server/types/types.ts | 31 +++ src/shared/protocol.test.ts | 126 ++++++++++ src/shared/protocol.ts | 14 +- src/shared/transport.ts | 7 +- 14 files changed, 641 insertions(+), 226 deletions(-) create mode 100644 src/server/types/types.ts diff --git a/package-lock.json b/package-lock.json index 40bad9fe..1a9a8f45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 467800fc..764ce2cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", + "version": "1.12.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index bbfa80fa..f80459f1 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -20,7 +20,14 @@ import { import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; +import { RequestInfo } from "../server/types/types.js"; +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + }, +}; /*** * Test: Initialize with Matching Protocol Version */ @@ -42,7 +49,7 @@ test("should initialize with matching protocol version", async () => { }, instructions: "test instructions", }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), @@ -100,7 +107,7 @@ test("should initialize with supported older protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), @@ -150,7 +157,7 @@ test("should reject unsupported protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7c0fbc51..e015be94 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -19,6 +19,14 @@ import { import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; import { Client } from "../client/index.js"; +import { RequestInfo } from "./types/types.js"; + +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }, +}; test("should accept latest protocol version", async () => { let sendPromiseResolve: (value: unknown) => void; @@ -77,7 +85,7 @@ test("should accept latest protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -138,7 +146,7 @@ test("should accept supported older protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -198,7 +206,7 @@ test("should handle unsupported protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..773777cb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,14 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { RequestInfo } from "./types/types.js"; + +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + }, +}; describe("McpServer", () => { /*** @@ -212,7 +220,8 @@ describe("ResourceTemplate", () => { signal: abortController.signal, requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, - sendNotification: () => { throw new Error("Not implemented") } + sendNotification: () => { throw new Error("Not implemented") }, + requestInfo: mockRequestInfo }); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); @@ -913,18 +922,10 @@ describe("tool()", () => { name: "test server", version: "1.0", }); - - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1056,17 +1057,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema mcpServer.registerTool( @@ -1169,17 +1163,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns only content without structuredContent mcpServer.registerTool( @@ -1233,17 +1220,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns invalid data mcpServer.registerTool( @@ -1308,17 +1288,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedSessionId: string | undefined; mcpServer.tool("test-tool", async (extra) => { @@ -1364,17 +1337,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.tool("request-id-test", async (extra) => { @@ -1423,17 +1389,10 @@ describe("tool()", () => { { capabilities: { logging: {} } }, ); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedLogMessage: string | undefined; const loggingMessage = "hello here is log message 1"; @@ -1480,17 +1439,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1546,17 +1498,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("error-test", async () => { throw new Error("Tool execution failed"); @@ -1598,17 +1543,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("test-tool", async () => ({ content: [ @@ -2392,6 +2330,48 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a resource template with a complete callback is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{category}", { + list: undefined, + complete: { + category: () => ["books", "movies", "music"], + }, + }), + async () => ({ + contents: [ + { + uri: "test://resource/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Resource Template Parameter Completion */ @@ -2401,17 +2381,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2469,17 +2442,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2540,17 +2506,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { @@ -3052,17 +3011,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test", @@ -3258,17 +3210,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt("test-prompt", async () => ({ messages: [ @@ -3303,6 +3248,49 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a prompt with a completable argument is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Prompt Argument Completion */ @@ -3312,17 +3300,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3380,17 +3361,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3450,17 +3424,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.prompt("request-id-test", async (extra) => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..38c869c7 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -236,6 +236,10 @@ export class McpServer { CompleteRequestSchema.shape.method.value, ); + this.server.registerCapabilities({ + completions: {}, + }); + this.server.setRequestHandler( CompleteRequestSchema, async (request): Promise => { diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..7edef6af 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,20 +1,146 @@ import http from 'http'; import { jest } from '@jest/globals'; import { SSEServerTransport } from './sse.js'; +import { McpServer } from './mcp.js'; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { z } from 'zod'; +import { CallToolResult, JSONRPCMessage } from 'src/types.js'; const createMockResponse = () => { const res = { - writeHead: jest.fn(), - write: jest.fn().mockReturnValue(true), - on: jest.fn(), + writeHead: jest.fn().mockReturnThis(), + write: jest.fn().mockReturnThis(), + on: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), }; - res.writeHead.mockReturnThis(); - res.on.mockReturnThis(); - return res as unknown as http.ServerResponse; + return res as unknown as jest.Mocked; }; +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServerWithSse(args: { + mockRes: http.ServerResponse; +}): Promise<{ + server: Server; + transport: SSEServerTransport; + mcpServer: McpServer; + baseUrl: URL; + sessionId: string + serverPort: number; +}> { + 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 endpoint = '/messages'; + + const transport = new SSEServerTransport(endpoint, args.mockRes); + const sessionId = transport.sessionId; + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + await transport.handlePostMessage(req, res); + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + const port = (server.address() as AddressInfo).port; + + return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; +} + +async function readAllSSEEvents(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('No readable stream'); + + const events: string[] = []; + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value) { + events.push(decoder.decode(value)); + } + } + } finally { + reader.releaseLock(); + } + + return events; +} + +/** + * Helper to send JSON-RPC request + */ +async function sendSsePostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...extraHeaders + }; + + if (sessionId) { + baseUrl.searchParams.set('sessionId', sessionId); + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + describe('SSEServerTransport', () => { + + async function initializeServer(baseUrl: URL): Promise { + const response = await sendSsePostRequest(baseUrl, { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + capabilities: { + }, + }, + + id: "init-1", + } as JSONRPCMessage); + + expect(response.status).toBe(202); + + const text = await readAllSSEEvents(response); + + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } + describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { const mockRes = createMockResponse(); @@ -105,5 +231,71 @@ describe('SSEServerTransport', () => { `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` ); }); + + /*** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + const mockRes = createMockResponse(); + const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); + await initializeServer(baseUrl); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + + expect(response.status).toBe(202); + + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + + const expectedMessage = { + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", + }, + { + type: "text", + text: JSON.stringify({ + headers: { + host: `127.0.0.1:${serverPort}`, + connection: 'keep-alive', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'node', + 'accept-encoding': 'gzip, deflate', + 'content-length': '124' + }, + }) + }, + ], + }, + jsonrpc: "2.0", + id: "call-1", + }; + expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); + }); }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..bac58c80 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -5,6 +5,7 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { AuthInfo } from "./auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "./types/types.js"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -20,7 +21,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -87,6 +88,7 @@ export class SSEServerTransport implements Transport { throw new Error(message); } const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; let body: string | unknown; try { @@ -106,7 +108,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -118,7 +120,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { + async handleMessage(message: unknown, extra: MessageExtraInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..83af86cc 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -206,6 +206,7 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag describe("StreamableHTTPServerTransport", () => { let server: Server; + let mcpServer: McpServer; let transport: StreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -214,6 +215,7 @@ describe("StreamableHTTPServerTransport", () => { const result = await createTestServer(); server = result.server; transport = result.transport; + mcpServer = result.mcpServer; baseUrl = result.baseUrl; }); @@ -345,6 +347,69 @@ describe("StreamableHTTPServerTransport", () => { }); }); + /*** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + sessionId = await initializeServer(); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { type: "text", text: "Hello, Test User!" }, + { type: "text", text: expect.any(String) } + ], + }, + id: "call-1", + }); + + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String), + }, + }); + }); + it("should reject requests without a valid session ID", async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..77941095 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -5,6 +5,7 @@ import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; import { AuthInfo } from "./auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "./types/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -113,7 +114,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string | undefined; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; @@ -318,6 +319,7 @@ export class StreamableHTTPServerTransport implements Transport { } const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; let rawMessage; if (parsedBody !== undefined) { @@ -395,7 +397,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message, { authInfo }); + this.onmessage?.(message, { authInfo, requestInfo }); } } else if (hasRequests) { // The default behavior is to use SSE streaming @@ -430,7 +432,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message, { authInfo }); + this.onmessage?.(message, { authInfo, requestInfo }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready diff --git a/src/server/types/types.ts b/src/server/types/types.ts new file mode 100644 index 00000000..1114e50b --- /dev/null +++ b/src/server/types/types.ts @@ -0,0 +1,31 @@ +import { AuthInfo } from "../auth/types.js"; + +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; +} \ No newline at end of file diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..05bc8f3b 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -27,9 +27,11 @@ class MockTransport implements Transport { describe("protocol tests", () => { let protocol: Protocol; let transport: MockTransport; + let sendSpy: jest.SpyInstance; beforeEach(() => { transport = new MockTransport(); + sendSpy = jest.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -63,6 +65,130 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + describe("_meta preservation with onprogress", () => { + test("should preserve existing _meta when adding progressToken", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should create _meta with progressToken when no _meta exists", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test" + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should not modify _meta when onprogress is not provided", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + + protocol.request(request, mockSchema); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should handle params being undefined with onprogress", async () => { + await protocol.connect(transport); + const request = { + method: "example" + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + }); + describe("progress notification timeout behavior", () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4694929d..ae539c17 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -25,6 +25,7 @@ import { } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "../server/types/types.js"; /** * Callback for progress notifications. @@ -127,6 +128,11 @@ export type RequestHandlerExtra void; + onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; /** * The session ID generated for this connection. From 6dd0b1ee4d9ff5e2043cfab7f6391ef4e8ff54b0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 15 Jun 2025 21:47:53 -0400 Subject: [PATCH 18/44] Update readme file to include a tip to allow `mcp-session-id` in CORS when --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index c9e27c27..241056e5 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,21 @@ app.delete('/mcp', handleSessionRequest); app.listen(3000); ``` +> [!TIP] +> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. +> +> For example, in Node.js you can configure it like this: +> +> ```ts +> app.use( +> cors({ +> origin: '*', +> exposedHeaders: ['mcp-session-id'], +> allowedHeaders: ['Content-Type', 'mcp-session-id'], +> }) +> ); +> ``` + #### Without Session Management (Stateless) For simpler use cases where session management isn't needed: From 5a8af45c09a9b75da06e372817ed9c9b89b3ee10 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 16 Jun 2025 16:34:46 -0400 Subject: [PATCH 19/44] In src/examples/server/simpleStreamableHttp.ts - In mcpPostHandler, - Get `mcp-session-id` header early so that it can be reported in every incoming request. - Helpful for troubleshooting Inspector's ability to retain the session id for the Proxy <-> Server leg --- src/examples/server/simpleStreamableHttp.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920..dfc1f197 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -178,10 +178,10 @@ const getServer = () => { server.registerResource( 'example-file-1', 'file:///example/file1.txt', - { + { title: 'Example File 1', description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -198,10 +198,10 @@ const getServer = () => { server.registerResource( 'example-file-2', 'file:///example/file2.txt', - { + { title: 'Example File 2', description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -338,15 +338,13 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body); if (useOAuth && req.auth) { console.log('Authenticated user:', req.auth); } try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; From c5922f8765c17e3ba070fd43e57b12071306623c Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 17 Jun 2025 10:07:59 -0700 Subject: [PATCH 20/44] Fix node 18 incompatibility caused by race condition with closing server by using a newly allocated port for each test --- src/server/streamableHttp.test.ts | 36 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index d36bb8f2..adbc9025 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,5 +1,5 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; -import { AddressInfo } from "node:net"; +import { createServer as netCreateServer, AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; @@ -7,6 +7,20 @@ import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; import { AuthInfo } from "./auth/types.js"; +async function getFreePort() { + return new Promise( res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()! + if (typeof address === "string") { + throw new Error("Unexpected address type: " + typeof address); + } + const port = (address as AddressInfo).port; + srv.close((err) => res(port)) + }); + }) +} + /** * Test server configuration for StreamableHTTPServerTransport tests */ @@ -1441,7 +1455,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should accept requests with allowed host headers", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], enableDnsRebindingProtection: true, }); server = result.server; @@ -1563,7 +1577,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should skip all validations when enableDnsRebindingProtection is false", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3000'], enableDnsRebindingProtection: false, }); @@ -1591,7 +1605,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should validate both host and origin when both are configured", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3001'], enableDnsRebindingProtection: true, }); @@ -1649,6 +1663,17 @@ async function createTestServerWithDnsProtection(config: { { capabilities: { logging: {} } } ); + const port = await getFreePort(); + + if (config.allowedHosts) { + config.allowedHosts = config.allowedHosts.map(host => { + if (host.includes(':')) { + return host; + } + return `localhost:${port}`; + }); + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, allowedHosts: config.allowedHosts, @@ -1672,10 +1697,9 @@ async function createTestServerWithDnsProtection(config: { }); await new Promise((resolve) => { - httpServer.listen(3001, () => resolve()); + httpServer.listen(port, () => resolve()); }); - const port = (httpServer.address() as AddressInfo).port; const serverUrl = new URL(`http://localhost:${port}/`); return { From e76faf6a18bcd25724649a57b07c410957510192 Mon Sep 17 00:00:00 2001 From: joeyzzeng Date: Wed, 18 Jun 2025 21:34:42 +0800 Subject: [PATCH 21/44] fix: skip validation if tool reports error --- src/server/mcp.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7fb6bd55..242f0529 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1204,6 +1204,72 @@ describe("tool()", () => { ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: "text", + text: `Processed: ${input}`, + }, + ], + isError: true, + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to not throw an error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, + }); + }); + /*** * Test: Schema Validation Failure for Invalid Structured Content */ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da..9440708d 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema) { + if (tool.outputSchema && (result.isError !== true)) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From b293911df72f6191a8262beba4495fcaf80abb08 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 18 Jun 2025 21:56:22 -0600 Subject: [PATCH 22/44] add overloads for registerResource method in McpServer class Otherwise, TypeScript can't properly distinguish the type of callback --- src/server/mcp.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da..ee85ab59 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -613,6 +613,18 @@ export class McpServer { * Registers a resource with a config object and callback. * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. */ + registerResource( + name: string, + uriOrTemplate: string, + config: ResourceMetadata, + readCallback: ReadResourceCallback + ): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, From 87da0e0c3a96d9d3bc251d158c42579aeef0b6fd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 19 Jun 2025 17:52:24 +0100 Subject: [PATCH 23/44] adjust default validation for resource parameter in client flow, and server example --- src/client/auth.test.ts | 2 +- src/client/auth.ts | 14 +++---- src/examples/server/simpleStreamableHttp.ts | 3 +- src/shared/auth-utils.test.ts | 35 +++++++++++++++- src/shared/auth-utils.ts | 44 ++++++++++++++++++++- 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b99e4c90..532e13a3 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1041,7 +1041,7 @@ describe("OAuth Authorization", () => { // Verify custom validation method was called expect(mockValidateResourceURL).toHaveBeenCalledWith( - "https://api.example.com/mcp-server", + new URL("https://api.example.com/mcp-server"), "https://different-resource.example.com/mcp-server" ); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 28d9d833..c97d4f0b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; +import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -198,13 +198,13 @@ export async function auth( } async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + const resource = resourceUrlFromServerUrl(serverUrl); if (provider.validateResourceURL) { - return await provider.validateResourceURL(serverUrl, resourceMetadata?.resource); - } - - const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); - if (resourceMetadata && resourceMetadata.resource !== resource.href) { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); + return await provider.validateResourceURL(resource, resourceMetadata?.resource); + } else if (resourceMetadata) { + if (!checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); + } } return resource; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6406bc21..09d30da2 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -9,6 +9,7 @@ import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDe import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; +import { checkResourceAllowed } from 'src/shared/auth-utils.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); @@ -463,7 +464,7 @@ if (useOAuth) { if (!data.aud) { throw new Error(`Resource Indicator (RFC8707) missing`); } - if (data.aud !== mcpServerUrl.href) { + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); } } diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index c35bb122..c1fa7bdf 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,4 +1,4 @@ -import { resourceUrlFromServerUrl } from './auth-utils.js'; +import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -27,4 +27,35 @@ describe('auth-utils', () => { expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); }); }); -}); \ No newline at end of file + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true); + }); + + it('should not match URLs with different paths', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false); + }); + }); +}); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index 086d812f..97a77c01 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -7,8 +7,48 @@ * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". * Keeps everything else unchanged (scheme, domain, port, path, query). */ -export function resourceUrlFromServerUrl(url: URL): URL { - const resourceURL = new URL(url.href); +export function resourceUrlFromServerUrl(url: URL | string ): URL { + const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href); resourceURL.hash = ''; // Remove fragment return resourceURL; } + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param requestedResource The resource URL being requested + * @param configuredResource The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ + export function checkResourceAllowed( + { requestedResource, configuredResource }: { + requestedResource: URL | string; + configuredResource: URL | string + } + ): boolean { + const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href); + const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); + } From eff548c06f493ffaa3de9d38a33e5b32b0b4e093 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 19 Jun 2025 18:02:00 +0100 Subject: [PATCH 24/44] adjust to provided resource --- src/client/auth.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 9 ++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 532e13a3..f95cb2ca 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1045,5 +1045,66 @@ describe("OAuth Authorization", () => { "https://different-resource.example.com/mcp-server" ); }); + + it("uses prefix of server URL from PRM resource as resource parameter", async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: "https://api.example.com/", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server/endpoint", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter from PRM + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index c97d4f0b..680fefd0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -197,12 +197,15 @@ export async function auth( return "REDIRECT"; } -async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { - const resource = resourceUrlFromServerUrl(serverUrl); +export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + let resource = resourceUrlFromServerUrl(serverUrl); if (provider.validateResourceURL) { return await provider.validateResourceURL(resource, resourceMetadata?.resource); } else if (resourceMetadata) { - if (!checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + // If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request. + resource = new URL(resourceMetadata.resource); + } else { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); } } From f4b8a48ded019a54a38d3d150a013427d6cbdbc6 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 19 Jun 2025 22:34:13 -0700 Subject: [PATCH 25/44] feat: remove console statements from SDK code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all console.log, console.warn, and console.error from src/client and src/server - Add ESLint no-console rule for client and server directories (excluding tests) - Keep console statements in test files, examples, and CLI tools as intended Addresses feedback in PR #665 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- eslint.config.mjs | 7 ++++++ src/client/auth.ts | 10 +++----- src/client/index.ts | 4 +-- src/server/auth/handlers/authorize.ts | 2 -- src/server/auth/handlers/register.ts | 1 - src/server/auth/handlers/revoke.ts | 31 ++++++++++++++---------- src/server/auth/handlers/token.ts | 2 -- src/server/auth/middleware/bearerAuth.ts | 1 - src/server/auth/middleware/clientAuth.ts | 1 - 9 files changed, 31 insertions(+), 28 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 515114cf..d792f015 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,5 +15,12 @@ export default tseslint.config( { "argsIgnorePattern": "^_" } ] } + }, + { + files: ["src/client/**/*.ts", "src/server/**/*.ts"], + ignores: ["**/*.test.ts"], + rules: { + "no-console": "error" + } } ); diff --git a/src/client/auth.ts b/src/client/auth.ts index 28d9d833..f84efa05 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -116,8 +116,8 @@ export async function auth( if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) + } catch { + // Ignore errors and fall back to /.well-known/oauth-authorization-server } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); @@ -175,8 +175,8 @@ export async function auth( await provider.saveTokens(newTokens); return "AUTHORIZED"; - } catch (error) { - console.error("Could not refresh OAuth tokens:", error); + } catch { + // Could not refresh OAuth tokens } } @@ -222,7 +222,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { const [type, scheme] = authenticateHeader.split(' '); if (type.toLowerCase() !== 'bearer' || !scheme) { - console.log("Invalid WWW-Authenticate header format, expected 'Bearer'"); return undefined; } const regex = /resource_metadata="([^"]*)"/; @@ -235,7 +234,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { try { return new URL(match[1]); } catch { - console.log("Invalid resource metadata url: ", match[1]); return undefined; } } diff --git a/src/client/index.ts b/src/client/index.ts index f3d440b9..3e8d8ec8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -486,8 +486,8 @@ export class Client< try { const validator = this._ajv.compile(tool.outputSchema); this._cachedToolOutputValidators.set(tool.name, validator); - } catch (error) { - console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`); + } catch { + // Ignore schema compilation errors } } } diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 0a6283a8..126ce006 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -99,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error looking up client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } @@ -146,7 +145,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A if (error instanceof OAuthError) { res.redirect(302, createErrorRedirect(redirect_uri, error, state)); } else { - console.error("Unexpected error during authorization:", error); const serverError = new ServerError("Internal Server Error"); res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); } diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 30b7cdf8..c3137348 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -104,7 +104,6 @@ export function clientRegistrationHandler({ const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error registering client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts index 95e8b4b3..0d1b30e0 100644 --- a/src/server/auth/handlers/revoke.ts +++ b/src/server/auth/handlers/revoke.ts @@ -9,7 +9,7 @@ import { InvalidRequestError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, } from "../errors.js"; export type RevocationHandlerOptions = { @@ -21,7 +21,10 @@ export type RevocationHandlerOptions = { rateLimit?: Partial | false; }; -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { +export function revocationHandler({ + provider, + rateLimit: rateLimitConfig, +}: RevocationHandlerOptions): RequestHandler { if (!provider.revokeToken) { throw new Error("Auth provider does not support revoking tokens"); } @@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo // Apply rate limiting unless explicitly disabled if (rateLimitConfig !== false) { - router.use(rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - })); + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError( + "You have exceeded the rate limit for token revocation requests" + ).toResponseObject(), + ...rateLimitConfig, + }) + ); } // Authenticate and extract client details router.use(authenticateClient({ clientsStore: provider.clientsStore })); router.post("/", async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); + res.setHeader("Cache-Control", "no-store"); try { const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); @@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error revoking token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 1d97805b..b2ab7439 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -80,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -143,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error exchanging token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index fd96055a..91f763a9 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -88,7 +88,6 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } else if (error instanceof OAuthError) { res.status(400).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating bearer token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 76049c11..ecd9a7b6 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } From 9edb6196001fc6582c911f7c0650116161760fd5 Mon Sep 17 00:00:00 2001 From: Samuel Jensen <44519206+nichtsam@users.noreply.github.com> Date: Sat, 10 May 2025 00:51:54 +0200 Subject: [PATCH 26/44] make protocol class not overwrite existing hooks when connecting transports --- src/shared/protocol.test.ts | 16 ++++++++++++++++ src/shared/protocol.ts | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index 5c6b72d2..b16db73f 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -65,6 +65,22 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + test("should not overwrite existing hooks when connecting transports", async () => { + const oncloseMock = jest.fn(); + const onerrorMock = jest.fn(); + const onmessageMock = jest.fn(); + transport.onclose = oncloseMock; + transport.onerror = onerrorMock; + transport.onmessage = onmessageMock; + await protocol.connect(transport); + transport.onclose(); + transport.onerror(new Error()); + transport.onmessage(""); + expect(oncloseMock).toHaveBeenCalled(); + expect(onerrorMock).toHaveBeenCalled(); + expect(onmessageMock).toHaveBeenCalled(); + }); + describe("_meta preservation with onprogress", () => { test("should preserve existing _meta when adding progressToken", async () => { await protocol.connect(transport); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index a04f26eb..942f096a 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -279,15 +279,21 @@ export abstract class Protocol< */ async connect(transport: Transport): Promise { this._transport = transport; + const _onclose = this.transport?.onclose; this._transport.onclose = () => { + _onclose?.(); this._onclose(); }; + const _onerror = this.transport?.onerror; this._transport.onerror = (error: Error) => { + _onerror?.(error); this._onerror(error); }; + const _onmessage = this._transport?.onmessage; this._transport.onmessage = (message, extra) => { + _onmessage?.(message, extra); if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { @@ -295,7 +301,9 @@ export abstract class Protocol< } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { - this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + this._onerror( + new Error(`Unknown message type: ${JSON.stringify(message)}`), + ); } }; From d89e85413303896f768cc9d44203515b129cba91 Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Fri, 20 Jun 2025 12:54:29 -0700 Subject: [PATCH 27/44] fix(client/sse): extract protected resource from eventsource 401 Previously the SSE connection would always default to the `/.well-known/oauth-protected-resource` URI, ignoring the `resource_metadata` portion of the `www-authenticate` returned in a 401. Extract the metadata from the initial 401, so RS servers with custom protected resource URIs (as in RFC9728, [section 3.1][1])) continue to work as expected. [1]: https://datatracker.ietf.org/doc/html/rfc9728#section-3.1 --- src/client/sse.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5aa99abb..2546d508 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -117,23 +117,35 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { + const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, - this._eventSourceInit ?? { - fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, { - ...init, - headers: { - ...headers, - Accept: "text/event-stream" + { + ...this._eventSourceInit, + fetch: async (url, init) => { + const headers = await this._commonHeaders() + const response = await fetchImpl(url, { + ...init, + headers: new Headers({ + ...headers, + Accept: "text/event-stream" + }) + }) + + if (response.status === 401 && response.headers.has('www-authenticate')) { + this._resourceMetadataUrl = extractResourceMetadataUrl(response); } - })), + + return response + }, }, ); this._abortController = new AbortController(); this._eventSource.onerror = (event) => { if (event.code === 401 && this._authProvider) { + this._authThenStart().then(resolve, reject); return; } From 9c3ef4f9447ef941dc797ea2597ab40ee4ce2e42 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 23 Jun 2025 14:31:50 +0100 Subject: [PATCH 28/44] 1.13.1 --- 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 d14ac4f4..016adf94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index bb8022fa..0439e680 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4f24b8bc6faaea456d92f8296fbc84a560c3c8c6 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:36:54 +0800 Subject: [PATCH 29/44] Fix `/.well-known/oauth-authorization-server` dropping path --- src/client/auth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index d953e1f0..35105daa 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,7 +297,9 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + const url = new URL(wellKnownPath, authorizationServerUrl); + let response: Response; try { response = await fetch(url, { From da6ac79c1e2bcc1979f03ccaaf61094b5c9d4adf Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:41:26 +0800 Subject: [PATCH 30/44] Fix missing issuer --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 35105daa..33a9a6b9 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,6 +297,8 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { + const issuer = new URL(authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; const url = new URL(wellKnownPath, authorizationServerUrl); From 622070135242f9276b87de59b47a301fa7062cdc Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:13:13 -0400 Subject: [PATCH 31/44] Fix trailing slash --- src/client/auth.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 33a9a6b9..cba14a9c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -299,8 +299,12 @@ export async function discoverOAuthMetadata( ): Promise { const issuer = new URL(authorizationServerUrl); - const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - const url = new URL(wellKnownPath, authorizationServerUrl); + let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + if (issuer.pathname.endsWith('/')) { + // Strip trailing slash from pathname + wellKnownPath = wellKnownPath.slice(0, -1); + } + const url = new URL(wellKnownPath, issuer); let response: Response; try { From 1ff08e41d7088a63f208034a0f3bf3acfe5bf03e Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:32:08 -0400 Subject: [PATCH 32/44] Add path test --- src/client/auth.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f95cb2ca..511b351f 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,24 @@ describe("OAuth Authorization", () => { }); }); + it("returns metadata when discovery succeeds with path", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; From 03da7cfc66cf416c214bdf3236c775d7c4794c5a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 11:38:55 +0100 Subject: [PATCH 33/44] fallback --- src/client/auth.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 88 ++++++++++++++++++++++------- 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 511b351f..b689d188 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -225,6 +225,126 @@ describe("OAuth Authorization", () => { }); }); + it("falls back to root discovery when path-aware discovery returns 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(firstOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("returns undefined when both path-aware and root discovery return 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("does not fallback when the original URL is already at root path", async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("does not fallback when the original URL has no path", async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("falls back when path-aware discovery encounters CORS error", async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; diff --git a/src/client/auth.ts b/src/client/auth.ts index cba14a9c..e0e93fc0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -293,36 +293,82 @@ export async function discoverOAuthProtectedResourceMetadata( * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. */ +/** + * Helper function to handle fetch with CORS retry logic + */ +async function fetchWithCorsRetry( + url: URL, + headers: Record, +): Promise { + try { + return await fetch(url, { headers }); + } catch (error) { + // CORS errors come back as TypeError, retry without headers + if (error instanceof TypeError) { + return await fetch(url); + } + throw error; + } +} + +/** + * Constructs the well-known path for OAuth metadata discovery + */ +function buildWellKnownPath(pathname: string): string { + let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; + if (pathname.endsWith('/')) { + // Strip trailing slash from pathname to avoid double slashes + wellKnownPath = wellKnownPath.slice(0, -1); + } + return wellKnownPath; +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, +): Promise { + const headers = { + "MCP-Protocol-Version": protocolVersion + }; + return await fetchWithCorsRetry(url, headers); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback(response: Response, pathname: string): boolean { + return response.status === 404 && pathname !== '/'; +} + export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { const issuer = new URL(authorizationServerUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - if (issuer.pathname.endsWith('/')) { - // Strip trailing slash from pathname - wellKnownPath = wellKnownPath.slice(0, -1); - } - const url = new URL(wellKnownPath, issuer); + // Try path-aware discovery first (RFC 8414 compliant) + const wellKnownPath = buildWellKnownPath(issuer.pathname); + const pathAwareUrl = new URL(wellKnownPath, issuer); + let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION + // If path-aware discovery fails with 404, try fallback to root discovery + if (shouldAttemptFallback(response, issuer.pathname)) { + try { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + + if (response.status === 404) { + return undefined; } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; + } catch { + // If fallback fails, return undefined + return undefined; } - } - - if (response.status === 404) { + } else if (response.status === 404) { return undefined; } From 15a2277c8994a403dfedaf52d27eae73fdc359af Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 24 Jun 2025 14:47:54 +0100 Subject: [PATCH 34/44] refactor resource selection to not include resource if PRM is not present --- src/client/auth.test.ts | 231 ++++++++++++++++++++++++++++++++++++++-- src/client/auth.ts | 27 +++-- 2 files changed, 241 insertions(+), 17 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b689d188..8e77c0a5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -954,10 +954,19 @@ describe("OAuth Authorization", () => { }); it("passes resource parameter through authorization flow", async () => { - // Mock successful metadata discovery + // Mock successful metadata discovery - need to include protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1002,11 +1011,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token exchange when authorization code is provided", async () => { - // Mock successful metadata discovery and token exchange + // Mock successful metadata discovery and token exchange - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1062,11 +1080,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token refresh", async () => { - // Mock successful metadata discovery and token refresh + // Mock successful metadata discovery and token refresh - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1244,5 +1271,197 @@ describe("OAuth Authorization", () => { // Should use the PRM's resource value, not the full requested URL expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); }); + + it("excludes resource parameter when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("excludes resource parameter in token exchange when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + authorizationCode: "auth-code-123", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("excludes resource parameter in token refresh when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + 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"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index e0e93fc0..37690574 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -198,19 +198,24 @@ export async function auth( } export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { - let resource = resourceUrlFromServerUrl(serverUrl); + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it if (provider.validateResourceURL) { - return await provider.validateResourceURL(resource, resourceMetadata?.resource); - } else if (resourceMetadata) { - if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { - // If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request. - resource = new URL(resourceMetadata.resource); - } else { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); - } + return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); } - return resource; + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); + } + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); } /** @@ -360,7 +365,7 @@ export async function discoverOAuthMetadata( try { const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion); - + if (response.status === 404) { return undefined; } From c20a47a79f38f617ffd9ef0df1106651891d9ea7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 17:09:29 +0100 Subject: [PATCH 35/44] small fixes --- src/server/mcp.test.ts | 108 ++++++++++++++++++++--------------------- src/server/mcp.ts | 2 +- 2 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 242f0529..e09ab511 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1203,72 +1203,68 @@ describe("tool()", () => { }), ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); - - /*** + /*** * Test: Tool with Output Schema Must Provide Structured Content */ - test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - "test", - { - description: "Test tool with output schema but missing structured content", - inputSchema: { - input: z.string(), - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - }, + test("should skip outputSchema validation when isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ - { - type: "text", - text: `Processed: ${input}`, - }, - ], - isError: true, - }) - ); - - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool and expect it to not throw an error - await expect( - client.callTool({ - name: "test", - arguments: { - input: "hello", - }, - }), - ).resolves.toStrictEqual({ + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ content: [ { type: "text", - text: `Processed: hello`, + text: `Processed: ${input}`, }, ], isError: true, - }); + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, }); + }); /*** * Test: Schema Validation Failure for Invalid Structured Content diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9440708d..67da78ff 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema && (result.isError !== true)) { + if (tool.outputSchema && !result.isError) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From 166da76b0070a431c9f254a59e799a8c927f84fb Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 24 Jun 2025 19:39:37 +0300 Subject: [PATCH 36/44] extra parameter - remain optional for backwards compatibility --- package-lock.json | 1 + src/server/sse.ts | 4 ++-- src/server/streamableHttp.ts | 2 +- src/server/types/types.ts | 2 +- src/shared/protocol.ts | 6 +++--- src/shared/transport.ts | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9dd8236b..016adf94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,6 @@ { "name": "@modelcontextprotocol/sdk", + "version": "1.13.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/server/sse.ts b/src/server/sse.ts index 06c0bc8d..a54e5788 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -20,7 +20,7 @@ export class SSEServerTransport implements Transport { private _sessionId: string; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -119,7 +119,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, extra: MessageExtraInfo): Promise { + async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b5f8aca7..807743eb 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -114,7 +114,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; diff --git a/src/server/types/types.ts b/src/server/types/types.ts index 1114e50b..3892af6c 100644 --- a/src/server/types/types.ts +++ b/src/server/types/types.ts @@ -22,7 +22,7 @@ export interface MessageExtraInfo { /** * The request information. */ - requestInfo: RequestInfo; + requestInfo?: RequestInfo; /** * The authentication information. diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ae539c17..33afd70e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -131,7 +131,7 @@ export type RequestHandlerExtra void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * The session ID generated for this connection. From 05d98bb3a4b8feab427bf71f11c77b2266132a7b Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Wed, 25 Jun 2025 03:20:26 -0700 Subject: [PATCH 37/44] feat(shared/auth): support software_statement in OAuthClientMetadata (#696) Per [Section 3.1.1][ref], `software_statement` is an OPTIONAL member of the client creation request, which may contain a JWT encoding claims about client software. [ref]: https://datatracker.ietf.org/doc/html/rfc7591#section-3.1.1 --- src/shared/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 65b800e7..b906de3d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -98,6 +98,7 @@ export const OAuthClientMetadataSchema = z.object({ jwks: z.any().optional(), software_id: z.string().optional(), software_version: z.string().optional(), + software_statement: z.string().optional(), }).strip(); /** From 606c278668c4328b2592da73f59d1b98b2ccf062 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 25 Jun 2025 16:24:30 +0300 Subject: [PATCH 38/44] clean up tests - remove mockRequestInfo --- src/client/index.test.ts | 13 +++---------- src/server/index.test.ts | 16 ++++------------ src/server/mcp.test.ts | 13 ++----------- src/server/sse.test.ts | 2 +- src/server/sse.ts | 3 +-- src/server/streamableHttp.ts | 3 +-- src/server/types/types.ts | 31 ------------------------------- src/shared/protocol.ts | 3 ++- src/shared/transport.ts | 3 +-- src/types.ts | 31 +++++++++++++++++++++++++++++++ 10 files changed, 46 insertions(+), 72 deletions(-) delete mode 100644 src/server/types/types.ts diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 02d6781c..abd0c34e 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -21,14 +21,7 @@ import { import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; -import { RequestInfo } from "../server/types/types.js"; -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'accept': 'application/json', - }, -}; /*** * Test: Initialize with Matching Protocol Version */ @@ -50,7 +43,7 @@ test("should initialize with matching protocol version", async () => { }, instructions: "test instructions", }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), @@ -108,7 +101,7 @@ test("should initialize with supported older protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), @@ -158,7 +151,7 @@ test("should reject unsupported protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 137b8934..d91b90a9 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -15,19 +15,11 @@ import { ListResourcesRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, - ErrorCode, + ErrorCode } from "../types.js"; import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; import { Client } from "../client/index.js"; -import { RequestInfo } from "./types/types.js"; - -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', - }, -}; test("should accept latest protocol version", async () => { let sendPromiseResolve: (value: unknown) => void; @@ -86,7 +78,7 @@ test("should accept latest protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -147,7 +139,7 @@ test("should accept supported older protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -207,7 +199,7 @@ test("should handle unsupported protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index d208d51e..0764ffe8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,21 +14,13 @@ import { LoggingMessageNotificationSchema, Notification, TextContent, - ElicitRequestSchema, + ElicitRequestSchema } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; -import { RequestInfo } from "./types/types.js"; import { getDisplayName } from "../shared/metadataUtils.js"; -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'accept': 'application/json', - }, -}; - describe("McpServer", () => { /*** * Test: Basic Server Instance @@ -222,8 +214,7 @@ describe("ResourceTemplate", () => { signal: abortController.signal, requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, - sendNotification: () => { throw new Error("Not implemented") }, - requestInfo: mockRequestInfo + sendNotification: () => { throw new Error("Not implemented") } }); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 7edef6af..703cc514 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -232,7 +232,7 @@ describe('SSEServerTransport', () => { ); }); - /*** + /** * Test: Tool With Request Info */ it("should pass request info to tool callback", async () => { diff --git a/src/server/sse.ts b/src/server/sse.ts index a54e5788..de4dd60a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,11 +1,10 @@ import { randomUUID } from "node:crypto"; import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { AuthInfo } from "./auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "./types/types.js"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 807743eb..677da45e 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,10 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; +import { MessageExtraInfo, RequestInfo, isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; import { AuthInfo } from "./auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "./types/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; diff --git a/src/server/types/types.ts b/src/server/types/types.ts deleted file mode 100644 index 3892af6c..00000000 --- a/src/server/types/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AuthInfo } from "../auth/types.js"; - -/** - * Headers that are compatible with both Node.js and the browser. - */ -export type IsomorphicHeaders = Record; - -/** - * Information about the incoming request. - */ -export interface RequestInfo { - /** - * The headers of the request. - */ - headers: IsomorphicHeaders; -} - -/** - * Extra information about a message. - */ -export interface MessageExtraInfo { - /** - * The request information. - */ - requestInfo?: RequestInfo; - - /** - * The authentication information. - */ - authInfo?: AuthInfo; -} \ No newline at end of file diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 33afd70e..35839a4f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,10 +22,11 @@ import { Result, ServerCapabilities, RequestMeta, + MessageExtraInfo, + RequestInfo, } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "../server/types/types.js"; /** * Callback for progress notifications. diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 69fce10e..96b291fa 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,4 @@ -import { MessageExtraInfo } from "../server/types/types.js"; -import { JSONRPCMessage, RequestId } from "../types.js"; +import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js"; /** * Options for sending a JSON-RPC message. diff --git a/src/types.ts b/src/types.ts index 3606a6be..f66d2c4b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { z, ZodTypeAny } from "zod"; +import { AuthInfo } from "./server/auth/types.js"; export const LATEST_PROTOCOL_VERSION = "2025-06-18"; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; @@ -1463,6 +1464,36 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo?: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; +} + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; From 5f96ae4d8954daf656a9b69a73b249f748cfc75d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 25 Jun 2025 11:34:44 -0400 Subject: [PATCH 39/44] update PR#633 to address the comment about improving the example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index abf57afb..de297dc9 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,7 @@ app.listen(3000); > ```ts > app.use( > cors({ -> origin: '*', +> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], > exposedHeaders: ['mcp-session-id'], > allowedHeaders: ['Content-Type', 'mcp-session-id'], > }) From 362acfc1ce435f97987cf786838523645073f8fa Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 26 Jun 2025 09:51:26 -0700 Subject: [PATCH 40/44] Update package-lock.json --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b5d6e36..d6e1b9f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,12 @@ { "name": "@modelcontextprotocol/sdk", -<<<<<<< cb/sse-tests -- Incoming Change - "version": "1.10.2", -======= "version": "1.13.1", ->>>>>>> main -- Current Change "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", -<<<<<<< cb/sse-tests -- Incoming Change - "version": "1.10.2", -======= "version": "1.13.1", ->>>>>>> main -- Current Change "license": "MIT", "dependencies": { "ajv": "^6.12.6", From f76652bb100ee59470359ea440502cb1c02e7c56 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 26 Jun 2025 10:22:27 -0700 Subject: [PATCH 41/44] Update src/server/sse.test.ts --- src/server/sse.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 4aceb734..32c894f0 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -418,7 +418,12 @@ describe('SSEServerTransport', () => { }, { authInfo: { token: 'test-token', - } + }, + requestInfo: { + headers: { + 'content-type': 'application/json', + }, + }, }); }); }); From 9d678ce5912c86c1d12867b9a726365d9295c1a9 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 26 Jun 2025 18:52:24 +0100 Subject: [PATCH 42/44] 1.13.2 --- 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 d6e1b9f5..9f1d43a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 0439e680..8feb10af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 8bc7374f5ee69d4af79d6703fee244231a4b3d95 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 26 Jun 2025 16:12:26 -0700 Subject: [PATCH 43/44] Fix merge conflict --- src/server/sse.test.ts | 72 ++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index a0a06cb9..a7f18096 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -469,8 +469,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -489,8 +491,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'evil.com', - 'content-type': 'application/json', + headers: { + host: 'evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -509,7 +513,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json', + headers: { + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -530,8 +536,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -550,8 +558,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - origin: 'http://evil.com', - 'content-type': 'application/json', + headers: { + origin: 'http://evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -569,7 +579,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json', + headers: { + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -585,7 +597,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json; charset=utf-8', + headers: { + 'content-type': 'application/json; charset=utf-8', + } }); const mockHandleRes = createMockResponse(); @@ -601,7 +615,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'text/plain', + headers: { + 'content-type': 'text/plain', + } }); const mockHandleRes = createMockResponse(); @@ -623,9 +639,11 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'evil.com', - origin: 'http://evil.com', - 'content-type': 'text/plain', + headers: { + host: 'evil.com', + origin: 'http://evil.com', + 'content-type': 'text/plain', + } }); const mockHandleRes = createMockResponse(); @@ -650,9 +668,11 @@ describe('SSEServerTransport', () => { // Valid host, invalid origin const mockReq1 = createMockRequest({ - host: 'localhost:3000', - origin: 'http://evil.com', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + origin: 'http://evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes1 = createMockResponse(); @@ -663,9 +683,11 @@ describe('SSEServerTransport', () => { // Invalid host, valid origin const mockReq2 = createMockRequest({ - host: 'evil.com', - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'evil.com', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes2 = createMockResponse(); @@ -676,9 +698,11 @@ describe('SSEServerTransport', () => { // Both valid const mockReq3 = createMockRequest({ - host: 'localhost:3000', - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes3 = createMockResponse(); From 53ad0a01d56a978da8ca37cff99e484113cdf337 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 27 Jun 2025 14:14:21 +0100 Subject: [PATCH 44/44] fix lint --- src/server/streamableHttp.test.ts | 54 +++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index f93e7e96..502435ea 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -8,17 +8,17 @@ import { z } from "zod"; import { AuthInfo } from "./auth/types.js"; async function getFreePort() { - return new Promise( res => { - const srv = netCreateServer(); - srv.listen(0, () => { - const address = srv.address()! - if (typeof address === "string") { - throw new Error("Unexpected address type: " + typeof address); - } - const port = (address as AddressInfo).port; - srv.close((err) => res(port)) - }); - }) + return new Promise(res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()! + if (typeof address === "string") { + throw new Error("Unexpected address type: " + typeof address); + } + const port = (address as AddressInfo).port; + srv.close((_err) => res(port)) + }); + }) } /** @@ -377,7 +377,7 @@ describe("StreamableHTTPServerTransport", () => { return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; } ); - + const toolCallMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "tools/call", @@ -828,7 +828,7 @@ describe("StreamableHTTPServerTransport", () => { // Send request with matching protocol version const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - + expect(response.status).toBe(200); }); @@ -846,7 +846,7 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + expect(response.status).toBe(200); }); @@ -864,7 +864,7 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -872,13 +872,13 @@ describe("StreamableHTTPServerTransport", () => { it("should accept when protocol version differs from negotiated version", async () => { sessionId = await initializeServer(); - + // Spy on console.warn to verify warning is logged const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); // Send request with different but supported protocol version const response = await fetch(baseUrl, { - method: "POST", + method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", @@ -887,10 +887,10 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + // Request should still succeed expect(response.status).toBe(200); - + warnSpy.mockRestore(); }); @@ -906,7 +906,7 @@ describe("StreamableHTTPServerTransport", () => { "mcp-protocol-version": "invalid-version", }, }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -923,7 +923,7 @@ describe("StreamableHTTPServerTransport", () => { "mcp-protocol-version": "invalid-version", }, }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -965,12 +965,12 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { method: "tools/call", params: { name: "profile", - arguments: {active: true}, + arguments: { active: true }, }, id: "call-1", }; - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, {'authorization': 'Bearer test-token'}); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { 'authorization': 'Bearer test-token' }); expect(response.status).toBe(200); const text = await readSSEEvent(response); @@ -992,7 +992,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { id: "call-1", }); }); - + it("should calls tool without authInfo when it is optional", async () => { sessionId = await initializeServer(); @@ -1001,7 +1001,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { method: "tools/call", params: { name: "profile", - arguments: {active: false}, + arguments: { active: false }, }, id: "call-1", }; @@ -1485,7 +1485,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open first SSE stream const stream1 = await fetch(baseUrl, { method: "GET", - headers: { + headers: { Accept: "text/event-stream", "mcp-protocol-version": "2025-03-26" }, @@ -1495,7 +1495,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open second SSE stream - should still be rejected, stateless mode still only allows one const stream2 = await fetch(baseUrl, { method: "GET", - headers: { + headers: { Accept: "text/event-stream", "mcp-protocol-version": "2025-03-26" },