From 4cd5e73a1146834ec3d5822b874ee93e0eea00c0 Mon Sep 17 00:00:00 2001 From: Jun Han Date: Sun, 20 Apr 2025 17:51:46 +0800 Subject: [PATCH 001/208] docs: add error handling when it fails to start HTTP server --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 200cfab6f..8ddc117a2 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,11 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; setupServer().then(() => { - app.listen(PORT, () => { + app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); }); }).catch(error => { From 52476aa29726cdf193b8d50b48e63feb5e1bff77 Mon Sep 17 00:00:00 2001 From: muzea Date: Wed, 23 Apr 2025 14:41:58 +0800 Subject: [PATCH 002/208] 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 b83bf27c5..6572f0c03 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 003/208] 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 1165b7513..3c6e2d902 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 2fd2c0424..11705fe42 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 03f6fefc9..164780eff 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 cd359edabc864884d0dc6a48050e895ec228c15b Mon Sep 17 00:00:00 2001 From: sunrabbit123 Date: Thu, 24 Apr 2025 21:14:08 +0900 Subject: [PATCH 004/208] fix(client): No such file or directory in StdioClientTransport(#393, #196) Signed-off-by: sunrabbit123 --- src/client/stdio.test.ts | 30 ++++++++++++++++++++++++++++++ src/client/stdio.ts | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 646f9ea5d..e6ccb3472 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -59,3 +59,33 @@ test("should read messages", async () => { await client.close(); }); + +test("should work with actual node mcp server", async () => { + const client = new StdioClientTransport({ + command: "npx", + args: ["-y", "@wrtnlabs/calculator-mcp"], + }); + + await client.start(); + await client.close(); +}); + +test("should work with actual node mcp server and empty env", async () => { + const client = new StdioClientTransport({ + command: "npx", + args: ["-y", "@wrtnlabs/calculator-mcp"], + env: {}, + }); + await client.start(); + await client.close(); +}); + +test("should work with actual node mcp server and custom env", async () => { + const client = new StdioClientTransport({ + command: "npx", + args: ["-y", "@wrtnlabs/calculator-mcp"], + env: {TEST_VAR: "test-value"}, + }); + await client.start(); + await client.close(); +}); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index b83bf27c5..cef12fd88 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -117,7 +117,11 @@ export class StdioClientTransport implements Transport { this._serverParams.command, this._serverParams.args ?? [], { - env: this._serverParams.env ?? getDefaultEnvironment(), + // merge default env with server env because mcp server needs some env vars + env: { + ...getDefaultEnvironment(), + ...this._serverParams.env, + }, stdio: ["pipe", "pipe", this._serverParams.stderr ?? "inherit"], shell: false, signal: this._abortController.signal, From b71e0965202ac89f5817da15ab38837f1e1b75ea Mon Sep 17 00:00:00 2001 From: Leonid Domnitser Date: Mon, 28 Apr 2025 16:26:31 -0700 Subject: [PATCH 005/208] 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 1165b7513..9a277c918 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 f24053c5a..9b40c2b6f 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 9cca20115fc82425840b760921cf2bc293e35009 Mon Sep 17 00:00:00 2001 From: sunrabbit123 Date: Tue, 29 Apr 2025 13:35:23 +0900 Subject: [PATCH 006/208] fix: modify test code about stdio.test Signed-off-by: sunrabbit123 --- src/client/cross-spawn.test.ts | 7 +++- src/client/stdio.test.ts | 70 ++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/src/client/cross-spawn.test.ts b/src/client/cross-spawn.test.ts index 11e81bf63..98454a9ae 100644 --- a/src/client/cross-spawn.test.ts +++ b/src/client/cross-spawn.test.ts @@ -1,4 +1,4 @@ -import { StdioClientTransport } from "./stdio.js"; +import { getDefaultEnvironment, StdioClientTransport } from "./stdio.js"; import spawn from "cross-spawn"; import { JSONRPCMessage } from "../types.js"; import { ChildProcess } from "node:child_process"; @@ -72,7 +72,10 @@ describe("StdioClientTransport using cross-spawn", () => { "test-command", [], expect.objectContaining({ - env: customEnv + env: { + ...customEnv, + ...getDefaultEnvironment() + } }) ); }); diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index e6ccb3472..cc3731fb6 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,10 +1,21 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; +import { StdioClientTransport, StdioServerParameters, DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from "./stdio.js"; const serverParameters: StdioServerParameters = { command: "/usr/bin/tee", }; + +let spawnEnv: Record | undefined; + +jest.mock('cross-spawn', () => { + const originalSpawn = jest.requireActual('cross-spawn'); + return jest.fn((command, args, options) => { + spawnEnv = options.env; + return originalSpawn(command, args, options); + }); +}); + test("should start then close cleanly", async () => { const client = new StdioClientTransport(serverParameters); client.onerror = (error) => { @@ -60,32 +71,51 @@ test("should read messages", async () => { await client.close(); }); -test("should work with actual node mcp server", async () => { - const client = new StdioClientTransport({ - command: "npx", - args: ["-y", "@wrtnlabs/calculator-mcp"], - }); - - await client.start(); - await client.close(); -}); +test("should properly set default environment variables in spawned process", async () => { + const client = new StdioClientTransport(serverParameters); -test("should work with actual node mcp server and empty env", async () => { - const client = new StdioClientTransport({ - command: "npx", - args: ["-y", "@wrtnlabs/calculator-mcp"], - env: {}, - }); await client.start(); await client.close(); + + // Get the default environment variables + const defaultEnv = getDefaultEnvironment(); + + // Verify that all default environment variables are present + for (const key of DEFAULT_INHERITED_ENV_VARS) { + if (process.env[key] && !process.env[key].startsWith("()")) { + expect(spawnEnv).toHaveProperty(key); + expect(spawnEnv![key]).toBe(process.env[key]); + expect(spawnEnv![key]).toBe(defaultEnv[key]); + } + } }); -test("should work with actual node mcp server and custom env", async () => { +test("should override default environment variables with custom ones", async () => { + const customEnv = { + HOME: "/custom/home", + PATH: "/custom/path", + USER: "custom_user" + }; + const client = new StdioClientTransport({ - command: "npx", - args: ["-y", "@wrtnlabs/calculator-mcp"], - env: {TEST_VAR: "test-value"}, + ...serverParameters, + env: customEnv }); + await client.start(); await client.close(); + + // Verify that custom environment variables override default ones + for (const [key, value] of Object.entries(customEnv)) { + expect(spawnEnv).toHaveProperty(key); + expect(spawnEnv![key]).toBe(value); + } + + // Verify that other default environment variables are still present + for (const key of DEFAULT_INHERITED_ENV_VARS) { + if (!(key in customEnv) && process.env[key] && !process.env[key].startsWith("()")) { + expect(spawnEnv).toHaveProperty(key); + expect(spawnEnv![key]).toBe(process.env[key]); + } + } }); From 59fb4845d6e49bca6b63cb12a617872682d3133a Mon Sep 17 00:00:00 2001 From: Xiaofu Huang Date: Tue, 6 May 2025 17:25:38 +0800 Subject: [PATCH 007/208] 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 9e35293d3..4f7f5f1b3 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 008/208] 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 646f9ea5d..96dd5648d 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 009/208] 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 96dd5648d..b21324469 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 4f7f5f1b3..af29614ec 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 fd13cd93694e51f35ed38bee79f953f904bea505 Mon Sep 17 00:00:00 2001 From: Marco Pegoraro Date: Thu, 8 May 2025 13:14:30 +0000 Subject: [PATCH 010/208] doc minimum node version requirment --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c91022603..af3d2e29f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ The Model Context Protocol allows applications to provide context for LLMs in a npm install @modelcontextprotocol/sdk ``` +> ⚠️ MCP requires Node v18.x up to work fine. + ## Quick Start Let's create a simple MCP server that exposes a calculator tool and some data: 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 011/208] 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 ed986a694..fe738fcba 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 012/208] 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 f468969b5..0c954c412 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 013/208] 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 40bad9fe2..ef5393822 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 2fd2c0424..38ba9e599 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 03f6fefc9..6ef2baefa 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 014/208] 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 c9e27c275..ccc627b27 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 b961f6c41..68fe8ee7f 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 dc99c3065..8feeac558 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 015/208] 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 ef5393822..40bad9fe2 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 016/208] 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 6ef2baefa..65e8c7c89 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 017/208] 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 38ba9e599..9fb1f30c3 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 65e8c7c89..73c74bb3f 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 018/208] 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 f748a2be3..11dfe7d41 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 1bcfbb2d1..2bb0fdf4a 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 019/208] 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 ccc627b27..32037f904 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 9fb1f30c3..aee6eaf69 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 73c74bb3f..bd5d80b93 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 68fe8ee7f..4683024b1 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 8feeac558..084147dc7 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 020/208] 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 49f852d65..fd71443df 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 5b864b8b4..436a948fd 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 021/208] raw request propagation in tools - implementation, unit tests, types --- package-lock.json | 4 +- package.json | 2 +- src/client/index.test.ts | 15 +- src/server/index.test.ts | 14 +- src/server/mcp.test.ts | 365 ++++++++++++++---------------- 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 40bad9fe2..1a9a8f454 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 467800fc4..764ce2cbb 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 bbfa80faf..f80459f1f 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 7c0fbc51a..e015be94c 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 49f852d65..773777cbb 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: [ @@ -2393,26 +2331,61 @@ describe("resource()", () => { }); /*** - * Test: Resource Template Parameter Completion + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion */ - test("should support completion of resource template parameters", async () => { + 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", + }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, + 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 + */ + test("should support completion of resource template parameters", 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}", { @@ -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,27 +3248,63 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + /*** - * Test: Prompt Argument Completion + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion */ - test("should support completion of prompt arguments", async () => { + 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", + }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, + mcpServer.prompt( + "test-prompt", { - capabilities: { - prompts: {}, - }, + 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 + */ + test("should support completion of prompt arguments", 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", { @@ -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 5b864b8b4..38c869c78 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 2fd2c0424..7edef6af0 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 03f6fefc9..bac58c80a 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 b961f6c41..83af86cc8 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 dc99c3065..779410957 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 000000000..1114e50b7 --- /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 e0141da19..05bc8f3bc 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 4694929d7..ae539c177 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 022/208] 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 c9e27c275..241056e52 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 7b5bfcee529bc98a1f1cf668b90167855bc28ca0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 19:29:04 +0100 Subject: [PATCH 023/208] implementation of RFC 8707 Resource Indicators (Fixes #592, Fixes #635) --- src/client/auth.test.ts | 512 ++++++++++++++++++ src/client/auth.ts | 34 +- src/client/sse.ts | 20 +- src/client/streamableHttp.ts | 20 +- .../server/demoInMemoryOAuthProvider.test.ts | 218 ++++++++ .../server/demoInMemoryOAuthProvider.ts | 99 +++- .../server/resourceValidationExample.ts | 152 ++++++ .../server/serverUrlValidationExample.ts | 103 ++++ src/examples/server/strictModeExample.ts | 85 +++ src/server/auth/errors.ts | 10 + .../auth/handlers/authorize.config.test.ts | 361 ++++++++++++ src/server/auth/handlers/authorize.test.ts | 122 +++++ src/server/auth/handlers/authorize.ts | 38 +- src/server/auth/handlers/token.test.ts | 179 ++++++ src/server/auth/handlers/token.ts | 66 ++- src/server/auth/provider.ts | 6 +- .../auth/providers/proxyProvider.test.ts | 127 +++++ src/server/auth/providers/proxyProvider.ts | 15 +- src/server/auth/types.ts | 33 ++ src/shared/auth-utils.test.ts | 100 ++++ src/shared/auth-utils.ts | 44 ++ 21 files changed, 2311 insertions(+), 33 deletions(-) create mode 100644 src/examples/server/demoInMemoryOAuthProvider.test.ts create mode 100644 src/examples/server/resourceValidationExample.ts create mode 100644 src/examples/server/serverUrlValidationExample.ts create mode 100644 src/examples/server/strictModeExample.ts create mode 100644 src/server/auth/handlers/authorize.config.test.ts create mode 100644 src/shared/auth-utils.test.ts create mode 100644 src/shared/auth-utils.ts diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 1b9fb0712..9a0674057 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -341,6 +341,31 @@ describe("OAuth Authorization", () => { expect(codeVerifier).toBe("test_verifier"); }); + it("includes resource parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + resource: "https://api.example.com/mcp-server", + } + ); + + expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("resource")).toBe(false); + }); + it("includes scope parameter when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -489,6 +514,45 @@ describe("OAuth Authorization", () => { expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); }); + it("includes resource parameter in token exchange when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + resource: "https://api.example.com/mcp-server", + }); + + expect(tokens).toEqual(validTokens); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter from token exchange when not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.has("resource")).toBe(false); + }); + it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -576,6 +640,41 @@ describe("OAuth Authorization", () => { expect(body.get("client_secret")).toBe("secret123"); }); + it("includes resource parameter in refresh token request when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + const tokens = await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken: "refresh123", + resource: "https://api.example.com/mcp-server", + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter from refresh token request when not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken: "refresh123", + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.has("resource")).toBe(false); + }); + it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -807,5 +906,418 @@ describe("OAuth Authorization", () => { "https://resource.example.com/.well-known/oauth-authorization-server" ); }); + + it("canonicalizes resource URI by removing fragment", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 the auth function with a resource that has a fragment + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server#fragment", + }); + + expect(result).toBe("REDIRECT"); + + // Verify redirectToAuthorization was called with the canonicalized resource + 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]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("passes resource parameter through authorization flow", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 for authorization flow + (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 without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes 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]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("includes resource in token exchange when authorization code is provided", async () => { + // Mock successful metadata discovery and token exchange + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + 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://resource.example.com", + authorizationCode: "auth-code-123", + resource: "https://api.example.com/mcp-server", + }); + + 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; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("includes resource in token refresh", async () => { + // Mock successful metadata discovery and token refresh + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + 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://resource.example.com", + resource: "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; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + + it("handles empty resource parameter", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 empty resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "", + }); + + expect(result).toBe("REDIRECT"); + + // Verify that empty resource is not included in the URL + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("handles resource with multiple fragments", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 resource containing multiple # symbols + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server#fragment#another", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the resource is properly canonicalized (everything after first # removed) + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("verifies resource parameter distinguishes between different paths on same domain", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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); + + // Test with different resource paths on same domain + // This tests the security fix that prevents token confusion between + // multiple MCP servers on the same domain + const result1 = await auth(mockProvider, { + serverUrl: "https://api.example.com", + resource: "https://api.example.com/mcp-server-1/v1", + }); + + expect(result1).toBe("REDIRECT"); + + const redirectCall1 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl1: URL = redirectCall1[0]; + expect(authUrl1.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-1/v1"); + + // Clear mock calls + (mockProvider.redirectToAuthorization as jest.Mock).mockClear(); + + // Test with different path on same domain + const result2 = await auth(mockProvider, { + serverUrl: "https://api.example.com", + resource: "https://api.example.com/mcp-server-2/v1", + }); + + expect(result2).toBe("REDIRECT"); + + const redirectCall2 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl2: URL = redirectCall2[0]; + expect(authUrl2.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-2/v1"); + + // Verify that the two resources are different (critical for security) + expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); + }); + + it("preserves query parameters in resource URI", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 resource containing query parameters + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server?param=value&another=test", + }); + + expect(result).toBe("REDIRECT"); + + // Verify query parameters are preserved (only fragment is removed) + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server?param=value&another=test"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7a91eb256..9a9965f60 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,6 +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 { canonicalizeResourceUri } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -92,12 +93,20 @@ export async function auth( { serverUrl, authorizationCode, scope, - resourceMetadataUrl + resourceMetadataUrl, + resource }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL; + resource?: string }): Promise { + + // Remove fragment from resource parameter if provided + let canonicalResource: string | undefined; + if (resource) { + canonicalResource = canonicalizeResourceUri(resource); + } let authorizationServerUrl = serverUrl; try { @@ -142,6 +151,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, + resource: canonicalResource, }); await provider.saveTokens(tokens); @@ -158,6 +168,7 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, + resource: canonicalResource, }); await provider.saveTokens(newTokens); @@ -176,6 +187,7 @@ export async function auth( state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, + resource: canonicalResource, }); await provider.saveCodeVerifier(codeVerifier); @@ -310,12 +322,14 @@ export async function startAuthorization( redirectUrl, scope, state, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; state?: string; + resource?: string; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -365,6 +379,10 @@ export async function startAuthorization( authorizationUrl.searchParams.set("scope", scope); } + if (resource) { + authorizationUrl.searchParams.set("resource", resource); + } + return { authorizationUrl, codeVerifier }; } @@ -379,12 +397,14 @@ export async function exchangeAuthorization( authorizationCode, codeVerifier, redirectUri, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; redirectUri: string | URL; + resource?: string; }, ): Promise { const grantType = "authorization_code"; @@ -418,6 +438,10 @@ export async function exchangeAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { @@ -442,10 +466,12 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; + resource?: string; }, ): Promise { const grantType = "refresh_token"; @@ -477,6 +503,10 @@ export async function refreshAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { diff --git a/src/client/sse.ts b/src/client/sse.ts index 5aa99abb4..6c07cf252 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,6 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -86,7 +87,11 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -201,7 +206,12 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -236,7 +246,11 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 4117bb1b4..e452972bb 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,6 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -149,7 +150,11 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -362,7 +367,12 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -410,7 +420,11 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts new file mode 100644 index 000000000..49c6f69b1 --- /dev/null +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; +import { InvalidTargetError } from '../../server/auth/errors.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; +import { Response } from 'express'; + +describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { + let provider: DemoInMemoryAuthProvider; + let clientsStore: DemoInMemoryClientsStore; + let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] }; + let mockResponse: Partial; + + beforeEach(() => { + provider = new DemoInMemoryAuthProvider(); + clientsStore = provider.clientsStore as DemoInMemoryClientsStore; + + mockClient = { + client_id: 'test-client', + client_name: 'Test Client', + client_uri: 'https://example.com', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools', + token_endpoint_auth_method: 'none', + }; + + mockResponse = { + redirect: jest.fn(), + }; + }); + + describe('Authorization with resource parameter', () => { + it('should allow authorization when no resources are configured', async () => { + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should allow authorization when resource is in allowed list', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2']; + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should reject authorization when resource is not in allowed list', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1']; + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.forbidden.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + }); + + describe('Token exchange with resource validation', () => { + let authorizationCode: string; + + beforeEach(async () => { + await clientsStore.registerClient(mockClient); + + // Authorize without resource first + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + }, mockResponse as Response); + + // Extract authorization code from redirect call + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + authorizationCode = url.searchParams.get('code')!; + }); + + it('should exchange code successfully when resource matches', async () => { + // First authorize with a specific resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.example.com/v1' + ); + + expect(tokens).toHaveProperty('access_token'); + expect(tokens.token_type).toBe('bearer'); + }); + + it('should reject token exchange when resource does not match', async () => { + // First authorize with a specific resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + await expect(provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.different.com' + )).rejects.toThrow(InvalidTargetError); + }); + + it('should reject token exchange when resource was not authorized but is requested', async () => { + await expect(provider.exchangeAuthorizationCode( + mockClient, + authorizationCode, + undefined, + undefined, + 'https://api.example.com/v1' + )).rejects.toThrow(InvalidTargetError); + }); + + it('should store resource in token data', async () => { + // Authorize with resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.example.com/v1' + ); + + // Verify token has resource information + const tokenDetails = provider.getTokenDetails(tokens.access_token); + expect(tokenDetails?.resource).toBe('https://api.example.com/v1'); + }); + }); + + describe('Refresh token with resource validation', () => { + it('should validate resource when exchanging refresh token', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1']; + await clientsStore.registerClient(mockClient); + + await expect(provider.exchangeRefreshToken( + mockClient, + 'refresh-token', + undefined, + 'https://api.forbidden.com' + )).rejects.toThrow(InvalidTargetError); + }); + }); + + describe('Allowed resources management', () => { + it('should update allowed resources for a client', async () => { + await clientsStore.registerClient(mockClient); + + // Initially no resources configured + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://any.api.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + // Set allowed resources + clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']); + + // Now should reject unauthorized resources + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://any.api.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + }); +}); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 024208d61..66583e490 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -1,23 +1,38 @@ import { randomUUID } from 'node:crypto'; import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; import express, { Request, Response } from "express"; -import { AuthInfo } from 'src/server/auth/types.js'; -import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; +import { AuthInfo } from '../../server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; +import { InvalidTargetError } from '../../server/auth/errors.js'; +interface ExtendedClientInformation extends OAuthClientInformationFull { + allowed_resources?: string[]; +} + export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); + private clients = new Map(); async getClient(clientId: string) { return this.clients.get(clientId); } - async registerClient(clientMetadata: OAuthClientInformationFull) { + async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) { this.clients.set(clientMetadata.client_id, clientMetadata); return clientMetadata; } + + /** + * Demo method to set allowed resources for a client + */ + setAllowedResources(clientId: string, resources: string[]) { + const client = this.clients.get(clientId); + if (client) { + client.allowed_resources = resources; + } + } } /** @@ -28,18 +43,28 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { * - Persistent token storage * - Rate limiting */ +interface ExtendedAuthInfo extends AuthInfo { + resource?: string; + type?: string; +} + export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); - private tokens = new Map(); + private tokens = new Map(); async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, res: Response ): Promise { + // Validate resource parameter if provided + if (params.resource) { + await this.validateResource(client, params.resource); + } + const code = randomUUID(); const searchParams = new URLSearchParams({ @@ -78,7 +103,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { authorizationCode: string, // Note: code verifier is checked in token.ts by default // it's unused here for that reason. - _codeVerifier?: string + _codeVerifier?: string, + _redirectUri?: string, + resource?: string ): Promise { const codeData = this.codes.get(authorizationCode); if (!codeData) { @@ -89,15 +116,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } + // Validate that the resource matches what was authorized + if (resource !== codeData.params.resource) { + throw new InvalidTargetError('Resource parameter does not match the authorized resource'); + } + + // If resource was specified during authorization, validate it's still allowed + if (codeData.params.resource) { + await this.validateResource(client, codeData.params.resource); + } + this.codes.delete(authorizationCode); const token = randomUUID(); - const tokenData = { + const tokenData: ExtendedAuthInfo = { token, clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour - type: 'access' + type: 'access', + resource: codeData.params.resource }; this.tokens.set(token, tokenData); @@ -111,11 +149,16 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } async exchangeRefreshToken( - _client: OAuthClientInformationFull, + client: OAuthClientInformationFull, _refreshToken: string, - _scopes?: string[] + _scopes?: string[], + resource?: string ): Promise { - throw new Error('Not implemented for example demo'); + // Validate resource parameter if provided + if (resource) { + await this.validateResource(client, resource); + } + throw new Error('Refresh tokens not implemented for example demo'); } async verifyAccessToken(token: string): Promise { @@ -131,6 +174,33 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { expiresAt: Math.floor(tokenData.expiresAt / 1000), }; } + + /** + * Validates that the client is allowed to access the requested resource. + * In a real implementation, this would check against a database or configuration. + */ + private async validateResource(client: OAuthClientInformationFull, resource: string): Promise { + const extendedClient = client as ExtendedClientInformation; + + // If no resources are configured, allow any resource (for demo purposes) + if (!extendedClient.allowed_resources) { + return; + } + + // Check if the requested resource is in the allowed list + if (!extendedClient.allowed_resources.includes(resource)) { + throw new InvalidTargetError( + `Client is not authorized to access resource: ${resource}` + ); + } + } + + /** + * Get token details including resource information (for demo introspection endpoint) + */ + getTokenDetails(token: string): ExtendedAuthInfo | undefined { + return this.tokens.get(token); + } } @@ -164,11 +234,14 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { } const tokenInfo = await provider.verifyAccessToken(token); + // For demo purposes, we'll add a method to get token details + const tokenDetails = provider.getTokenDetails(token); res.json({ active: true, client_id: tokenInfo.clientId, scope: tokenInfo.scopes.join(' '), - exp: tokenInfo.expiresAt + exp: tokenInfo.expiresAt, + ...(tokenDetails?.resource && { aud: tokenDetails.resource }) }); return } catch (error) { diff --git a/src/examples/server/resourceValidationExample.ts b/src/examples/server/resourceValidationExample.ts new file mode 100644 index 000000000..880b9539b --- /dev/null +++ b/src/examples/server/resourceValidationExample.ts @@ -0,0 +1,152 @@ +/** + * Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0 + * + * This example shows how to configure and use resource validation in the MCP OAuth flow. + * RFC 8707 allows OAuth clients to specify which protected resource they intend to access, + * and enables authorization servers to restrict tokens to specific resources. + */ + +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; + +async function demonstrateResourceValidation() { + // Create the OAuth provider + const provider = new DemoInMemoryAuthProvider(); + const clientsStore = provider.clientsStore; + + // Register a client with specific allowed resources + const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = { + client_id: 'resource-aware-client', + client_name: 'Resource-Aware MCP Client', + client_uri: 'https://example.com', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools mcp:resources', + token_endpoint_auth_method: 'none', + // RFC 8707: Specify which resources this client can access + allowed_resources: [ + 'https://api.example.com/mcp/v1', + 'https://api.example.com/mcp/v2', + 'https://tools.example.com/mcp' + ] + }; + + await clientsStore.registerClient(clientWithResources); + + console.log('Registered client with allowed resources:', clientWithResources.allowed_resources); + + // Example 1: Authorization request with valid resource + try { + const mockResponse = { + redirect: (url: string) => { + console.log('✅ Authorization successful, redirecting to:', url); + } + }; + + await provider.authorize(clientWithResources, { + codeChallenge: 'S256-challenge-here', + redirectUri: clientWithResources.redirect_uris[0], + resource: 'https://api.example.com/mcp/v1', // Valid resource + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('Authorization failed:', error); + } + + // Example 2: Authorization request with invalid resource + try { + const mockResponse = { + redirect: (url: string) => { + console.log('Redirecting to:', url); + } + }; + + await provider.authorize(clientWithResources, { + codeChallenge: 'S256-challenge-here', + redirectUri: clientWithResources.redirect_uris[0], + resource: 'https://unauthorized.api.com/mcp', // Invalid resource + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('❌ Authorization failed as expected:', error instanceof Error ? error.message : String(error)); + } + + // Example 3: Client without resource restrictions + const openClient: OAuthClientInformationFull = { + client_id: 'open-client', + client_name: 'Open MCP Client', + client_uri: 'https://open.example.com', + redirect_uris: ['https://open.example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools', + token_endpoint_auth_method: 'none', + // No allowed_resources specified - can access any resource + }; + + await clientsStore.registerClient(openClient); + + try { + const mockResponse = { + redirect: (url: string) => { + console.log('✅ Open client can access any resource, redirecting to:', url); + } + }; + + await provider.authorize(openClient, { + codeChallenge: 'S256-challenge-here', + redirectUri: openClient.redirect_uris[0], + resource: 'https://any.api.com/mcp', // Any resource is allowed + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('Authorization failed:', error); + } + + // Example 4: Token introspection with resource information + // First, simulate getting a token with resource restriction + const mockAuthCode = 'demo-auth-code'; + const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode); + + if (mockTokenResponse) { + const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token); + console.log('\n📋 Token introspection result:'); + console.log('- Client ID:', tokenDetails?.clientId); + console.log('- Scopes:', tokenDetails?.scopes); + console.log('- Resource (aud):', tokenDetails?.resource); + console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction'); + } +} + +async function simulateTokenExchange( + provider: DemoInMemoryAuthProvider, + client: OAuthClientInformationFull, + authCode: string +) { + // This is a simplified simulation - in real usage, the auth code would come from the authorization flow + console.log('\n🔄 Simulating token exchange with resource validation...'); + + // Note: In a real implementation, you would: + // 1. Get the authorization code from the redirect after authorize() + // 2. Exchange it for tokens using the token endpoint + // 3. The resource parameter in the token request must match the one from authorization + + return { + access_token: 'demo-token-with-resource', + token_type: 'bearer', + expires_in: 3600, + scope: 'mcp:tools' + }; +} + +// Usage instructions +console.log('🚀 RFC 8707 Resource Indicators Demo\n'); +console.log('This example demonstrates how to:'); +console.log('1. Register clients with allowed resources'); +console.log('2. Validate resource parameters during authorization'); +console.log('3. Include resource information in tokens'); +console.log('4. Handle invalid_target errors\n'); + +// Run the demonstration +demonstrateResourceValidation().catch(console.error); \ No newline at end of file diff --git a/src/examples/server/serverUrlValidationExample.ts b/src/examples/server/serverUrlValidationExample.ts new file mode 100644 index 000000000..e88359bce --- /dev/null +++ b/src/examples/server/serverUrlValidationExample.ts @@ -0,0 +1,103 @@ +/** + * Example demonstrating server URL validation for RFC 8707 compliance + * + * This example shows how to configure an OAuth server to validate that + * the resource parameter in requests matches the server's own URL, + * ensuring tokens are only issued for this specific server. + */ + +import express from 'express'; +import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; +import { tokenHandler } from '../../server/auth/handlers/token.js'; +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthServerConfig } from '../../server/auth/types.js'; + +// The canonical URL where this MCP server is accessible +const SERVER_URL = 'https://api.example.com/mcp'; + +// Configuration that validates resource matches this server +const serverValidationConfig: OAuthServerConfig = { + // The server's canonical URL (without fragment) + serverUrl: SERVER_URL, + + // Enable validation that resource parameter matches serverUrl + // This also makes the resource parameter required + validateResourceMatchesServer: true +}; + +// Create the OAuth provider +const provider = new DemoInMemoryAuthProvider(); + +// Create Express app +const app = express(); + +// Configure authorization endpoint with server URL validation +app.use('/oauth/authorize', authorizationHandler({ + provider, + config: serverValidationConfig +})); + +// Configure token endpoint with server URL validation +app.use('/oauth/token', tokenHandler({ + provider, + config: serverValidationConfig +})); + +// Example scenarios +console.log('🔐 Server URL Validation Example\n'); +console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`); + +console.log('✅ Valid request examples:'); +console.log(`1. Resource matches server URL: + GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&... + Result: Authorization proceeds normally\n`); + +console.log(`2. Resource with query parameters (exact match required): + GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&... + Result: Rejected - resource must match exactly\n`); + +console.log('❌ Invalid request examples:'); +console.log(`1. Different domain: + GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&... + Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`); + +console.log(`2. Different path: + GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&... + Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`); + +console.log(`3. Missing resource (with validateResourceMatchesServer: true): + GET /oauth/authorize?client_id=my-client&... + Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`); + +console.log('🛡️ Security Benefits:'); +console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers'); +console.log('2. Ensures all tokens are scoped to this specific MCP server'); +console.log('3. Provides clear audit trail of resource access attempts'); +console.log('4. Protects against malicious clients trying to obtain tokens for other services\n'); + +console.log('📝 Configuration Notes:'); +console.log('- serverUrl should be the exact URL clients use to connect'); +console.log('- Fragments are automatically removed from both serverUrl and resource'); +console.log('- When validateResourceMatchesServer is true, resource parameter is required'); +console.log('- Validation ensures exact match between resource and serverUrl\n'); + +console.log('🔧 Implementation Tips:'); +console.log('1. Set serverUrl from environment variable for different deployments:'); +console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n'); + +console.log('2. For development environments, you might disable validation:'); +console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n'); + +console.log('3. Consider logging failed validation attempts for security monitoring:'); +console.log(' Monitor logs for patterns of invalid_target errors\n'); + +// Example of dynamic configuration based on environment +const productionConfig: OAuthServerConfig = { + serverUrl: process.env.MCP_SERVER_URL || SERVER_URL, + validateResourceMatchesServer: process.env.NODE_ENV === 'production' +}; + +console.log('🚀 Production configuration example:'); +console.log(JSON.stringify(productionConfig, null, 2)); + +export { app, provider, serverValidationConfig }; \ No newline at end of file diff --git a/src/examples/server/strictModeExample.ts b/src/examples/server/strictModeExample.ts new file mode 100644 index 000000000..5ff140d6b --- /dev/null +++ b/src/examples/server/strictModeExample.ts @@ -0,0 +1,85 @@ +/** + * Example demonstrating strict RFC 8707 enforcement mode + * + * This example shows how to configure an OAuth server that requires + * all requests to include a resource parameter, ensuring maximum + * security against token confusion attacks. + */ + +import express from 'express'; +import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; +import { tokenHandler } from '../../server/auth/handlers/token.js'; +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthServerConfig } from '../../server/auth/types.js'; + +// Strict mode configuration - validates resource matches server URL +const SERVER_URL = 'https://api.example.com/mcp'; +const strictConfig: OAuthServerConfig = { + serverUrl: SERVER_URL, + validateResourceMatchesServer: true +}; + +// Create the OAuth provider +const provider = new DemoInMemoryAuthProvider(); + +// Create Express app +const app = express(); + +// Configure authorization endpoint with strict mode +app.use('/oauth/authorize', authorizationHandler({ + provider, + config: strictConfig, + rateLimit: { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10 // limit each IP to 10 requests per window + } +})); + +// Configure token endpoint with strict mode +app.use('/oauth/token', tokenHandler({ + provider, + config: strictConfig, + rateLimit: { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20 // limit each IP to 20 requests per window + } +})); + +// Example of what happens with different requests: +console.log('🔒 Strict RFC 8707 Mode Example\n'); +console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`); + +console.log('✅ Valid request example:'); +console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`); + +console.log('❌ Invalid request examples:'); +console.log('1. Missing resource:'); +console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256'); +console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n'); + +console.log('2. Wrong resource:'); +console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp'); +console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`); + +console.log('📋 Benefits of server URL validation:'); +console.log('1. Prevents token confusion attacks - tokens can only be issued for this server'); +console.log('2. Ensures all tokens are properly scoped to this specific MCP server'); +console.log('3. No accidental token leakage to other services'); +console.log('4. Clear security boundary enforcement\n'); + +console.log('⚠️ Migration considerations:'); +console.log('1. Server must know its canonical URL (configure via environment variable)'); +console.log('2. All clients must send the exact matching resource parameter'); +console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)'); +console.log('4. Monitor logs to track adoption before enabling validation\n'); + +// Example middleware to track resource parameter usage +app.use((req, res, next) => { + if (req.path.includes('/oauth/')) { + const hasResource = req.query.resource || req.body?.resource; + console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`); + } + next(); +}); + +export { app, provider, strictConfig }; \ No newline at end of file diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 428199ce8..5c001bcda 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -189,3 +189,13 @@ export class InsufficientScopeError extends OAuthError { super("insufficient_scope", message, errorUri); } } + +/** + * Invalid target error - The requested resource is invalid, unknown, or malformed. + * (RFC 8707 - Resource Indicators for OAuth 2.0) + */ +export class InvalidTargetError extends OAuthError { + constructor(message: string, errorUri?: string) { + super("invalid_target", message, errorUri); + } +} diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts new file mode 100644 index 000000000..aa180c4b4 --- /dev/null +++ b/src/server/auth/handlers/authorize.config.test.ts @@ -0,0 +1,361 @@ +import express from "express"; +import request from "supertest"; +import { authorizationHandler } from "./authorize.js"; +import { OAuthServerProvider } from "../provider.js"; +import { OAuthServerConfig } from "../types.js"; +import { InvalidRequestError, InvalidTargetError } from "../errors.js"; + +describe("Authorization handler with config", () => { + let app: express.Application; + let mockProvider: jest.Mocked; + + beforeEach(() => { + app = express(); + + const mockClientsStore = { + getClient: jest.fn(), + registerClient: jest.fn(), + }; + + mockProvider = { + clientsStore: mockClientsStore, + authorize: jest.fn(), + exchangeAuthorizationCode: jest.fn(), + exchangeRefreshToken: jest.fn(), + challengeForAuthorizationCode: jest.fn(), + verifyAccessToken: jest.fn(), + } as jest.Mocked; + }); + + describe("validateResourceMatchesServer configuration", () => { + it("should throw error when validateResourceMatchesServer is true but serverUrl is not set", () => { + const invalidConfig: OAuthServerConfig = { + validateResourceMatchesServer: true + // serverUrl is missing + }; + + expect(() => { + authorizationHandler({ + provider: mockProvider, + config: invalidConfig + }); + }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + }); + }); + + describe("server URL validation (validateResourceMatchesServer: true)", () => { + const serverValidationConfig: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp", + validateResourceMatchesServer: true + }; + + beforeEach(() => { + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: serverValidationConfig + })); + }); + + it("should reject requests without resource parameter", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_request"); + expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); + }); + + it("should accept requests with resource parameter", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + expect(mockProvider.authorize).toHaveBeenCalledWith( + mockClient, + expect.objectContaining({ + resource: "https://api.example.com/mcp" + }), + expect.any(Object) + ); + }); + }); + + describe("warning mode (default behavior)", () => { + const warnConfig: OAuthServerConfig = { + // No configuration needed - warnings are always enabled by default + }; + + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: warnConfig + })); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it("should log warning when resource is missing", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("test-client is missing the resource parameter") + ); + }); + + it("should not log warning when resource is present", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + // Note: No silent mode test anymore - warnings are always enabled + + describe("server URL validation (validateResourceMatchesServer: true)", () => { + const serverValidationConfig: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp", + validateResourceMatchesServer: true + }; + + beforeEach(() => { + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: serverValidationConfig + })); + }); + + it("should accept requests when resource matches server URL", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + }); + + it("should reject requests when resource does not match server URL", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://different.api.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_target"); + expect(response.headers.location).toContain("does+not+match+this+server"); + }); + + it("should reject requests without resource parameter when validation is enabled", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_request"); + expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); + }); + + it("should handle server URL with fragment correctly", async () => { + // Reconfigure with a server URL that has a fragment (though it shouldn't) + const configWithFragment: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp#fragment", + validateResourceMatchesServer: true + }; + + app = express(); + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: configWithFragment + })); + + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" // No fragment + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index e921d5ea6..20a2af897 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -276,6 +276,128 @@ describe('Authorization Handler', () => { }); }); + describe('Resource parameter validation', () => { + it('accepts valid resource parameter', async () => { + const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api.example.com/resource', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + + it('rejects invalid resource parameter (non-URL)', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'not-a-url' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_request'); + expect(location.searchParams.get('error_description')).toContain('resource'); + }); + + it('handles authorization without resource parameter', async () => { + const mockProviderWithoutResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithoutResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: undefined, + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + + it('passes multiple resources if provided', async () => { + const mockProviderWithResources = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api1.example.com/resource', + state: 'test-state' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResources).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api1.example.com/resource', + state: 'test-state' + }), + expect.any(Object) + ); + }); + + it('validates resource parameter in POST requests', async () => { + const mockProviderPost = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .post('/authorize') + .type('form') + .send({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderPost).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api.example.com/resource' + }), + expect.any(Object) + ); + }); + }); + describe('Successful authorization', () => { it('handles successful authorization with all parameters', async () => { const response = await supertest(app) diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 3e9a336b1..dbed1b522 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -8,10 +8,12 @@ import { InvalidRequestError, InvalidClientError, InvalidScopeError, + InvalidTargetError, ServerError, TooManyRequestsError, OAuthError } from "../errors.js"; +import { OAuthServerConfig } from "../types.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -20,6 +22,10 @@ export type AuthorizationHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; + /** + * OAuth server configuration options + */ + config?: OAuthServerConfig; }; // Parameters that must be validated in order to issue redirects. @@ -35,9 +41,15 @@ const RequestAuthorizationParamsSchema = z.object({ code_challenge_method: z.literal("S256"), scope: z.string().optional(), state: z.string().optional(), + resource: z.string().url().optional(), }); -export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { +export function authorizationHandler({ provider, rateLimit: rateLimitConfig, config }: AuthorizationHandlerOptions): RequestHandler { + // Validate configuration + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + // Create a router to apply middleware const router = express.Router(); router.use(allowedMethods(["GET", "POST"])); @@ -115,9 +127,30 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A throw new InvalidRequestError(parseResult.error.message); } - const { scope, code_challenge } = parseResult.data; + const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + // Remove fragment from server URL if present (though it shouldn't have one) + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Authorization request from client ${client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } + // Validate scopes let requestedScopes: string[] = []; if (scope !== undefined) { @@ -138,6 +171,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, + resource, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index c165fe7ff..68794c36b 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -282,6 +282,99 @@ describe('Token Handler', () => { expect(response.body.refresh_token).toBe('mock_refresh_token'); }); + it('accepts and passes resource parameter to provider', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + 'https://api.example.com/resource' // resource parameter + ); + }); + + it('rejects invalid resource parameter (non-URL)', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + resource: 'not-a-url' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('resource'); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + undefined // resource parameter + ); + }); + + it('passes resource with redirect_uri', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + redirect_uri: 'https://example.com/callback', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + 'https://example.com/callback', // redirect_uri + 'https://api.example.com/resource' // resource parameter + ); + }); + it('passes through code verifier when using proxy provider', async () => { const originalFetch = global.fetch; @@ -472,6 +565,92 @@ describe('Token Handler', () => { expect(response.status).toBe(200); expect(response.body.scope).toBe('profile email'); }); + + it('accepts and passes resource parameter to provider on refresh', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + 'https://api.example.com/resource' // resource parameter + ); + }); + + it('rejects invalid resource parameter (non-URL) on refresh', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + resource: 'not-a-url' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('resource'); + }); + + it('handles refresh token exchange without resource parameter', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + undefined // resource parameter + ); + }); + + it('passes resource with scopes on refresh', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + scope: 'profile email', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + ['profile', 'email'], // scopes + 'https://api.example.com/resource' // resource parameter + ); + }); }); describe('CORS support', () => { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index eadbd7515..37950502e 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -12,8 +12,10 @@ import { UnsupportedGrantTypeError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, + InvalidTargetError } from "../errors.js"; +import { OAuthServerConfig } from "../types.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -22,6 +24,10 @@ export type TokenHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; + /** + * OAuth server configuration options + */ + config?: OAuthServerConfig; }; const TokenRequestSchema = z.object({ @@ -32,14 +38,21 @@ const AuthorizationCodeGrantSchema = z.object({ code: z.string(), code_verifier: z.string(), redirect_uri: z.string().optional(), + resource: z.string().url().optional(), }); const RefreshTokenGrantSchema = z.object({ refresh_token: z.string(), scope: z.string().optional(), + resource: z.string().url().optional(), }); -export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { +export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: TokenHandlerOptions): RequestHandler { + // Validate configuration + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -89,7 +102,27 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { code, code_verifier, redirect_uri } = parseResult.data; + const { code, code_verifier, redirect_uri, resource } = parseResult.data; + + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -107,7 +140,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand client, code, skipLocalPkceValidation ? code_verifier : undefined, - redirect_uri + redirect_uri, + resource ); res.status(200).json(tokens); break; @@ -119,10 +153,30 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { refresh_token, scope } = parseResult.data; + const { refresh_token, scope, resource } = parseResult.data; + + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); res.status(200).json(tokens); break; } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7815b713e..256984166 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,6 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; + resource?: string; }; /** @@ -40,13 +41,14 @@ export interface OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: string ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: string): Promise; /** * Verifies an access token and returns information about it. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 69039c3e0..b652390b0 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -103,6 +103,49 @@ describe("Proxy OAuth Server Provider", () => { expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); + + it('includes resource parameter in authorization redirect', async () => { + await provider.authorize( + validClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + scopes: ['read', 'write'], + resource: 'https://api.example.com/resource' + }, + mockResponse + ); + + const expectedUrl = new URL('https://auth.example.com/authorize'); + expectedUrl.searchParams.set('client_id', 'test-client'); + expectedUrl.searchParams.set('response_type', 'code'); + expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); + expectedUrl.searchParams.set('code_challenge', 'test-challenge'); + expectedUrl.searchParams.set('code_challenge_method', 'S256'); + expectedUrl.searchParams.set('state', 'test-state'); + expectedUrl.searchParams.set('scope', 'read write'); + expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); + + expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); + }); + + it('handles authorization without resource parameter', async () => { + await provider.authorize( + validClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + scopes: ['read'] + }, + mockResponse + ); + + const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectUrl); + expect(url.searchParams.has('resource')).toBe(false); + }); }); describe("token exchange", () => { @@ -164,6 +207,41 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in authorization code exchange', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier', + 'https://example.com/callback', + 'https://api.example.com/resource' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + it("exchanges refresh token for new tokens", async () => { const tokens = await provider.exchangeRefreshToken( validClient, @@ -184,6 +262,55 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in refresh token exchange', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read', 'write'], + 'https://api.example.com/resource' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles refresh token exchange without resource parameter', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read'] + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes both scope and resource parameters in refresh', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['profile', 'email'], + 'https://api.example.com/resource' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).toContain('scope=profile+email'); + expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/resource')); + expect(tokens).toEqual(mockTokenResponse); + }); + }); describe("client registration", () => { diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index db7460e55..7f8b8d3df 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); + if (params.resource) searchParams.set("resource", params.resource); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: string ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("redirect_uri", redirectUri); } + if (resource) { + params.append("resource", resource); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { @@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeRefreshToken( client: OAuthClientInformationFull, refreshToken: string, - scopes?: string[] + scopes?: string[], + resource?: string ): Promise { const params = new URLSearchParams({ @@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("scope", scopes.join(" ")); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b602..33ba3f865 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -27,4 +27,37 @@ export interface AuthInfo { * This field should be used for any additional data that needs to be attached to the auth info. */ extra?: Record; +} + +/** + * Configuration options for OAuth server behavior + */ +export interface OAuthServerConfig { + /** + * The canonical URL of this MCP server. When provided, the server will validate + * that the resource parameter in OAuth requests matches this URL. + * + * This should be the full URL that clients use to connect to this server, + * without any fragment component (e.g., "https://api.example.com/mcp"). + * + * Required when validateResourceMatchesServer is true. + */ + serverUrl?: string; + + /** + * If true, validates that the resource parameter matches the configured serverUrl. + * + * When enabled: + * - serverUrl must be configured (throws error if not) + * - resource parameter is required on all requests + * - resource must exactly match serverUrl (after fragment removal) + * - requests without resource parameter will be rejected with invalid_request error + * - requests with non-matching resource will be rejected with invalid_target error + * + * When disabled: + * - warnings are logged when resource parameter is missing (for migration tracking) + * + * @default false + */ + validateResourceMatchesServer?: boolean; } \ No newline at end of file diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts new file mode 100644 index 000000000..1c45511a5 --- /dev/null +++ b/src/shared/auth-utils.test.ts @@ -0,0 +1,100 @@ +import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect(resourceUrlFromServerUrl('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl('https://example.com#fragment')).toBe('https://example.com'); + expect(resourceUrlFromServerUrl('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + }); + + it('should return URL unchanged if no fragment', () => { + expect(resourceUrlFromServerUrl('https://example.com')).toBe('https://example.com'); + expect(resourceUrlFromServerUrl('https://example.com/path')).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect(resourceUrlFromServerUrl('HTTPS://EXAMPLE.COM/PATH')).toBe('HTTPS://EXAMPLE.COM/PATH'); + // Ports preserved + expect(resourceUrlFromServerUrl('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(resourceUrlFromServerUrl('https://example.com:8080/path')).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect(resourceUrlFromServerUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com?foo=bar&baz=qux'); + // Trailing slashes preserved + expect(resourceUrlFromServerUrl('https://example.com/')).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl('https://example.com/path/')).toBe('https://example.com/path/'); + }); + }); + + describe('canonicalizeResourceUri', () => { + it('should remove fragments', () => { + expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + }); + + it('should keep everything else unchanged', () => { + expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + }); + }); + + describe('validateResourceUri', () => { + it('should accept valid resource URIs without fragments', () => { + expect(() => validateResourceUri('https://example.com')).not.toThrow(); + expect(() => validateResourceUri('https://example.com/path')).not.toThrow(); + expect(() => validateResourceUri('http://example.com:8080')).not.toThrow(); + expect(() => validateResourceUri('https://example.com?query=1')).not.toThrow(); + expect(() => validateResourceUri('ftp://example.com')).not.toThrow(); // Only fragment check now + }); + + it('should reject URIs with fragments', () => { + expect(() => validateResourceUri('https://example.com#fragment')).toThrow('must not contain a fragment'); + expect(() => validateResourceUri('https://example.com/path#section')).toThrow('must not contain a fragment'); + expect(() => validateResourceUri('https://example.com?query=1#anchor')).toThrow('must not contain a fragment'); + }); + + it('should accept any URI without fragment', () => { + // These are all valid now since we only check for fragments + expect(() => validateResourceUri('//example.com')).not.toThrow(); + expect(() => validateResourceUri('https://user:pass@example.com')).not.toThrow(); + expect(() => validateResourceUri('/path')).not.toThrow(); + expect(() => validateResourceUri('path')).not.toThrow(); + }); + }); + + describe('extractCanonicalResourceUri', () => { + it('should remove fragments from URLs', () => { + expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + }); + + it('should handle URL object', () => { + const url = new URL('https://example.com:8443/path?query=1#fragment'); + expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Preserves case + expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + // Preserves all ports + expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); + // Preserves query parameters + expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + // Preserves trailing slashes + expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/'); + expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); + }); + + it('should distinguish between different paths on same domain', () => { + // This is the key test for the security concern mentioned + const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1'); + const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2'); + expect(app1).not.toBe(app2); + expect(app1).toBe('https://api.example.com/mcp-server-1'); + expect(app2).toBe('https://api.example.com/mcp-server-2'); + }); + }); +}); \ No newline at end of file diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts new file mode 100644 index 000000000..aed5f247f --- /dev/null +++ b/src/shared/auth-utils.ts @@ -0,0 +1,44 @@ +/** + * Utilities for handling OAuth resource URIs according to RFC 8707. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * 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: string): string { + const hashIndex = url.indexOf('#'); + return hashIndex === -1 ? url : url.substring(0, hashIndex); +} + +/** + * Validates a resource URI according to RFC 8707 requirements. + * @param resourceUri The resource URI to validate + * @throws Error if the URI contains a fragment + */ +export function validateResourceUri(resourceUri: string): void { + if (resourceUri.includes('#')) { + throw new Error(`Invalid resource URI: ${resourceUri} - must not contain a fragment`); + } +} + +/** + * Removes fragment from URI to make it RFC 8707 compliant. + * @deprecated Use resourceUrlFromServerUrl instead + */ +export function canonicalizeResourceUri(resourceUri: string): string { + return resourceUrlFromServerUrl(resourceUri); +} + +/** + * Extracts resource URI from server URL by removing fragment. + * @param serverUrl The server URL to extract from + * @returns The resource URI without fragment + */ +export function extractResourceUri(serverUrl: string | URL): string { + return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); +} + +// Backward compatibility alias +export const extractCanonicalResourceUri = extractResourceUri; \ No newline at end of file From 1f4e42c09ea8c42db79afa63d2abea74a57473a1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 20:05:02 +0100 Subject: [PATCH 024/208] cleanup auth-utils and remove example files Co-Authored-By: Claude --- src/client/auth.ts | 4 +- src/client/sse.ts | 8 +- src/client/streamableHttp.ts | 8 +- .../server/resourceValidationExample.ts | 152 ------------------ .../server/serverUrlValidationExample.ts | 103 ------------ src/examples/server/strictModeExample.ts | 85 ---------- src/server/auth/handlers/authorize.ts | 3 +- src/server/auth/handlers/token.ts | 5 +- src/shared/auth-utils.test.ts | 37 ++--- src/shared/auth-utils.ts | 13 +- 10 files changed, 29 insertions(+), 389 deletions(-) delete mode 100644 src/examples/server/resourceValidationExample.ts delete mode 100644 src/examples/server/serverUrlValidationExample.ts delete mode 100644 src/examples/server/strictModeExample.ts diff --git a/src/client/auth.ts b/src/client/auth.ts index 9a9965f60..28188b7c0 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 { canonicalizeResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -105,7 +105,7 @@ export async function auth( // Remove fragment from resource parameter if provided let canonicalResource: string | undefined; if (resource) { - canonicalResource = canonicalizeResourceUri(resource); + canonicalResource = resourceUrlFromServerUrl(resource); } let authorizationServerUrl = serverUrl; diff --git a/src/client/sse.ts b/src/client/sse.ts index 6c07cf252..c484bde96 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; +import { extractResourceUri } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -90,7 +90,7 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +210,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +249,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index e452972bb..25c41bf3f 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; +import { extractResourceUri } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -153,7 +153,7 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +371,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/examples/server/resourceValidationExample.ts b/src/examples/server/resourceValidationExample.ts deleted file mode 100644 index 880b9539b..000000000 --- a/src/examples/server/resourceValidationExample.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0 - * - * This example shows how to configure and use resource validation in the MCP OAuth flow. - * RFC 8707 allows OAuth clients to specify which protected resource they intend to access, - * and enables authorization servers to restrict tokens to specific resources. - */ - -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthClientInformationFull } from '../../shared/auth.js'; - -async function demonstrateResourceValidation() { - // Create the OAuth provider - const provider = new DemoInMemoryAuthProvider(); - const clientsStore = provider.clientsStore; - - // Register a client with specific allowed resources - const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = { - client_id: 'resource-aware-client', - client_name: 'Resource-Aware MCP Client', - client_uri: 'https://example.com', - redirect_uris: ['https://example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools mcp:resources', - token_endpoint_auth_method: 'none', - // RFC 8707: Specify which resources this client can access - allowed_resources: [ - 'https://api.example.com/mcp/v1', - 'https://api.example.com/mcp/v2', - 'https://tools.example.com/mcp' - ] - }; - - await clientsStore.registerClient(clientWithResources); - - console.log('Registered client with allowed resources:', clientWithResources.allowed_resources); - - // Example 1: Authorization request with valid resource - try { - const mockResponse = { - redirect: (url: string) => { - console.log('✅ Authorization successful, redirecting to:', url); - } - }; - - await provider.authorize(clientWithResources, { - codeChallenge: 'S256-challenge-here', - redirectUri: clientWithResources.redirect_uris[0], - resource: 'https://api.example.com/mcp/v1', // Valid resource - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('Authorization failed:', error); - } - - // Example 2: Authorization request with invalid resource - try { - const mockResponse = { - redirect: (url: string) => { - console.log('Redirecting to:', url); - } - }; - - await provider.authorize(clientWithResources, { - codeChallenge: 'S256-challenge-here', - redirectUri: clientWithResources.redirect_uris[0], - resource: 'https://unauthorized.api.com/mcp', // Invalid resource - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('❌ Authorization failed as expected:', error instanceof Error ? error.message : String(error)); - } - - // Example 3: Client without resource restrictions - const openClient: OAuthClientInformationFull = { - client_id: 'open-client', - client_name: 'Open MCP Client', - client_uri: 'https://open.example.com', - redirect_uris: ['https://open.example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools', - token_endpoint_auth_method: 'none', - // No allowed_resources specified - can access any resource - }; - - await clientsStore.registerClient(openClient); - - try { - const mockResponse = { - redirect: (url: string) => { - console.log('✅ Open client can access any resource, redirecting to:', url); - } - }; - - await provider.authorize(openClient, { - codeChallenge: 'S256-challenge-here', - redirectUri: openClient.redirect_uris[0], - resource: 'https://any.api.com/mcp', // Any resource is allowed - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('Authorization failed:', error); - } - - // Example 4: Token introspection with resource information - // First, simulate getting a token with resource restriction - const mockAuthCode = 'demo-auth-code'; - const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode); - - if (mockTokenResponse) { - const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token); - console.log('\n📋 Token introspection result:'); - console.log('- Client ID:', tokenDetails?.clientId); - console.log('- Scopes:', tokenDetails?.scopes); - console.log('- Resource (aud):', tokenDetails?.resource); - console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction'); - } -} - -async function simulateTokenExchange( - provider: DemoInMemoryAuthProvider, - client: OAuthClientInformationFull, - authCode: string -) { - // This is a simplified simulation - in real usage, the auth code would come from the authorization flow - console.log('\n🔄 Simulating token exchange with resource validation...'); - - // Note: In a real implementation, you would: - // 1. Get the authorization code from the redirect after authorize() - // 2. Exchange it for tokens using the token endpoint - // 3. The resource parameter in the token request must match the one from authorization - - return { - access_token: 'demo-token-with-resource', - token_type: 'bearer', - expires_in: 3600, - scope: 'mcp:tools' - }; -} - -// Usage instructions -console.log('🚀 RFC 8707 Resource Indicators Demo\n'); -console.log('This example demonstrates how to:'); -console.log('1. Register clients with allowed resources'); -console.log('2. Validate resource parameters during authorization'); -console.log('3. Include resource information in tokens'); -console.log('4. Handle invalid_target errors\n'); - -// Run the demonstration -demonstrateResourceValidation().catch(console.error); \ No newline at end of file diff --git a/src/examples/server/serverUrlValidationExample.ts b/src/examples/server/serverUrlValidationExample.ts deleted file mode 100644 index e88359bce..000000000 --- a/src/examples/server/serverUrlValidationExample.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Example demonstrating server URL validation for RFC 8707 compliance - * - * This example shows how to configure an OAuth server to validate that - * the resource parameter in requests matches the server's own URL, - * ensuring tokens are only issued for this specific server. - */ - -import express from 'express'; -import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; -import { tokenHandler } from '../../server/auth/handlers/token.js'; -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthServerConfig } from '../../server/auth/types.js'; - -// The canonical URL where this MCP server is accessible -const SERVER_URL = 'https://api.example.com/mcp'; - -// Configuration that validates resource matches this server -const serverValidationConfig: OAuthServerConfig = { - // The server's canonical URL (without fragment) - serverUrl: SERVER_URL, - - // Enable validation that resource parameter matches serverUrl - // This also makes the resource parameter required - validateResourceMatchesServer: true -}; - -// Create the OAuth provider -const provider = new DemoInMemoryAuthProvider(); - -// Create Express app -const app = express(); - -// Configure authorization endpoint with server URL validation -app.use('/oauth/authorize', authorizationHandler({ - provider, - config: serverValidationConfig -})); - -// Configure token endpoint with server URL validation -app.use('/oauth/token', tokenHandler({ - provider, - config: serverValidationConfig -})); - -// Example scenarios -console.log('🔐 Server URL Validation Example\n'); -console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`); - -console.log('✅ Valid request examples:'); -console.log(`1. Resource matches server URL: - GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&... - Result: Authorization proceeds normally\n`); - -console.log(`2. Resource with query parameters (exact match required): - GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&... - Result: Rejected - resource must match exactly\n`); - -console.log('❌ Invalid request examples:'); -console.log(`1. Different domain: - GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&... - Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`); - -console.log(`2. Different path: - GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&... - Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`); - -console.log(`3. Missing resource (with validateResourceMatchesServer: true): - GET /oauth/authorize?client_id=my-client&... - Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`); - -console.log('🛡️ Security Benefits:'); -console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers'); -console.log('2. Ensures all tokens are scoped to this specific MCP server'); -console.log('3. Provides clear audit trail of resource access attempts'); -console.log('4. Protects against malicious clients trying to obtain tokens for other services\n'); - -console.log('📝 Configuration Notes:'); -console.log('- serverUrl should be the exact URL clients use to connect'); -console.log('- Fragments are automatically removed from both serverUrl and resource'); -console.log('- When validateResourceMatchesServer is true, resource parameter is required'); -console.log('- Validation ensures exact match between resource and serverUrl\n'); - -console.log('🔧 Implementation Tips:'); -console.log('1. Set serverUrl from environment variable for different deployments:'); -console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n'); - -console.log('2. For development environments, you might disable validation:'); -console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n'); - -console.log('3. Consider logging failed validation attempts for security monitoring:'); -console.log(' Monitor logs for patterns of invalid_target errors\n'); - -// Example of dynamic configuration based on environment -const productionConfig: OAuthServerConfig = { - serverUrl: process.env.MCP_SERVER_URL || SERVER_URL, - validateResourceMatchesServer: process.env.NODE_ENV === 'production' -}; - -console.log('🚀 Production configuration example:'); -console.log(JSON.stringify(productionConfig, null, 2)); - -export { app, provider, serverValidationConfig }; \ No newline at end of file diff --git a/src/examples/server/strictModeExample.ts b/src/examples/server/strictModeExample.ts deleted file mode 100644 index 5ff140d6b..000000000 --- a/src/examples/server/strictModeExample.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Example demonstrating strict RFC 8707 enforcement mode - * - * This example shows how to configure an OAuth server that requires - * all requests to include a resource parameter, ensuring maximum - * security against token confusion attacks. - */ - -import express from 'express'; -import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; -import { tokenHandler } from '../../server/auth/handlers/token.js'; -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthServerConfig } from '../../server/auth/types.js'; - -// Strict mode configuration - validates resource matches server URL -const SERVER_URL = 'https://api.example.com/mcp'; -const strictConfig: OAuthServerConfig = { - serverUrl: SERVER_URL, - validateResourceMatchesServer: true -}; - -// Create the OAuth provider -const provider = new DemoInMemoryAuthProvider(); - -// Create Express app -const app = express(); - -// Configure authorization endpoint with strict mode -app.use('/oauth/authorize', authorizationHandler({ - provider, - config: strictConfig, - rateLimit: { - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10 // limit each IP to 10 requests per window - } -})); - -// Configure token endpoint with strict mode -app.use('/oauth/token', tokenHandler({ - provider, - config: strictConfig, - rateLimit: { - windowMs: 15 * 60 * 1000, // 15 minutes - max: 20 // limit each IP to 20 requests per window - } -})); - -// Example of what happens with different requests: -console.log('🔒 Strict RFC 8707 Mode Example\n'); -console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`); - -console.log('✅ Valid request example:'); -console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`); - -console.log('❌ Invalid request examples:'); -console.log('1. Missing resource:'); -console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256'); -console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n'); - -console.log('2. Wrong resource:'); -console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp'); -console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`); - -console.log('📋 Benefits of server URL validation:'); -console.log('1. Prevents token confusion attacks - tokens can only be issued for this server'); -console.log('2. Ensures all tokens are properly scoped to this specific MCP server'); -console.log('3. No accidental token leakage to other services'); -console.log('4. Clear security boundary enforcement\n'); - -console.log('⚠️ Migration considerations:'); -console.log('1. Server must know its canonical URL (configure via environment variable)'); -console.log('2. All clients must send the exact matching resource parameter'); -console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)'); -console.log('4. Monitor logs to track adoption before enabling validation\n'); - -// Example middleware to track resource parameter usage -app.use((req, res, next) => { - if (req.path.includes('/oauth/')) { - const hasResource = req.query.resource || req.body?.resource; - console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`); - } - next(); -}); - -export { app, provider, strictConfig }; \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index dbed1b522..946c46c9d 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -14,6 +14,7 @@ import { OAuthError } from "../errors.js"; import { OAuthServerConfig } from "../types.js"; +import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -137,7 +138,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig, con } // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 37950502e..7af42d7ad 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -16,6 +16,7 @@ import { InvalidTargetError } from "../errors.js"; import { OAuthServerConfig } from "../types.js"; +import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -110,7 +111,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); } - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( @@ -161,7 +162,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); } - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index 1c45511a5..b95714081 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,4 +1,4 @@ -import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; +import { validateResourceUri, extractResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -28,17 +28,6 @@ describe('auth-utils', () => { }); }); - describe('canonicalizeResourceUri', () => { - it('should remove fragments', () => { - expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - }); - - it('should keep everything else unchanged', () => { - expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); - expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); - }); - }); describe('validateResourceUri', () => { it('should accept valid resource URIs without fragments', () => { @@ -64,34 +53,34 @@ describe('auth-utils', () => { }); }); - describe('extractCanonicalResourceUri', () => { + describe('extractResourceUri', () => { it('should remove fragments from URLs', () => { - expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + expect(extractResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(extractResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); }); it('should handle URL object', () => { const url = new URL('https://example.com:8443/path?query=1#fragment'); - expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1'); + expect(extractResourceUri(url)).toBe('https://example.com:8443/path?query=1'); }); it('should keep everything else unchanged', () => { // Preserves case - expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + expect(extractResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); // Preserves all ports - expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); + expect(extractResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(extractResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); // Preserves query parameters - expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + expect(extractResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); // Preserves trailing slashes - expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/'); - expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); + expect(extractResourceUri('https://example.com/')).toBe('https://example.com/'); + expect(extractResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); }); it('should distinguish between different paths on same domain', () => { // This is the key test for the security concern mentioned - const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1'); - const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2'); + const app1 = extractResourceUri('https://api.example.com/mcp-server-1'); + const app2 = extractResourceUri('https://api.example.com/mcp-server-2'); expect(app1).not.toBe(app2); expect(app1).toBe('https://api.example.com/mcp-server-1'); expect(app2).toBe('https://api.example.com/mcp-server-2'); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index aed5f247f..e69d821da 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -23,14 +23,6 @@ export function validateResourceUri(resourceUri: string): void { } } -/** - * Removes fragment from URI to make it RFC 8707 compliant. - * @deprecated Use resourceUrlFromServerUrl instead - */ -export function canonicalizeResourceUri(resourceUri: string): string { - return resourceUrlFromServerUrl(resourceUri); -} - /** * Extracts resource URI from server URL by removing fragment. * @param serverUrl The server URL to extract from @@ -38,7 +30,4 @@ export function canonicalizeResourceUri(resourceUri: string): string { */ export function extractResourceUri(serverUrl: string | URL): string { return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); -} - -// Backward compatibility alias -export const extractCanonicalResourceUri = extractResourceUri; \ No newline at end of file +} \ No newline at end of file From cba6a6ea589e8e5276fe1aef5c4efa17e99adafa Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 20:41:56 +0100 Subject: [PATCH 025/208] Update authorize.config.test.ts Co-Authored-By: Claude --- src/server/auth/handlers/authorize.config.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts index aa180c4b4..f0736da21 100644 --- a/src/server/auth/handlers/authorize.config.test.ts +++ b/src/server/auth/handlers/authorize.config.test.ts @@ -3,7 +3,6 @@ import request from "supertest"; import { authorizationHandler } from "./authorize.js"; import { OAuthServerProvider } from "../provider.js"; import { OAuthServerConfig } from "../types.js"; -import { InvalidRequestError, InvalidTargetError } from "../errors.js"; describe("Authorization handler with config", () => { let app: express.Application; From ccccb4b7ccb98b06b3537bf9eebc29bfc3c00368 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 21:34:06 +0100 Subject: [PATCH 026/208] simplify PR / only keep verification in demo inmemory oauth provider Co-Authored-By: Claude --- .../server/demoInMemoryOAuthProvider.test.ts | 175 ++++++++- .../server/demoInMemoryOAuthProvider.ts | 107 +++++- src/examples/server/simpleStreamableHttp.ts | 7 +- .../auth/handlers/authorize.config.test.ts | 360 ------------------ src/server/auth/handlers/authorize.ts | 36 +- src/server/auth/handlers/token.ts | 58 +-- src/server/auth/types.ts | 33 -- 7 files changed, 290 insertions(+), 486 deletions(-) delete mode 100644 src/server/auth/handlers/authorize.config.test.ts diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 49c6f69b1..852f0c98f 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; -import { InvalidTargetError } from '../../server/auth/errors.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore, DemoOAuthProviderConfig } from './demoInMemoryOAuthProvider.js'; +import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; import { OAuthClientInformationFull } from '../../shared/auth.js'; import { Response } from 'express'; @@ -215,4 +215,175 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); }); }); + + describe('Server URL validation configuration', () => { + it('should throw error when validateResourceMatchesServer is true but serverUrl is not set', () => { + const invalidConfig: DemoOAuthProviderConfig = { + validateResourceMatchesServer: true + // serverUrl is missing + }; + + expect(() => { + new DemoInMemoryAuthProvider(invalidConfig); + }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + }); + + describe('with server URL validation enabled', () => { + let strictProvider: DemoInMemoryAuthProvider; + + beforeEach(() => { + const config: DemoOAuthProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true + }; + strictProvider = new DemoInMemoryAuthProvider(config); + + strictProvider.clientsStore.registerClient(mockClient); + }); + + it('should reject authorization without resource parameter', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + // resource is missing + }, mockResponse as Response)).rejects.toThrow(InvalidRequestError); + }); + + it('should reject authorization with non-matching resource', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://different.api.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + + it('should accept authorization with matching resource', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should handle server URL with fragment correctly', async () => { + const configWithFragment: DemoOAuthProviderConfig = { + serverUrl: 'https://api.example.com/mcp#fragment', + validateResourceMatchesServer: true + }; + const providerWithFragment = new DemoInMemoryAuthProvider(configWithFragment); + + await providerWithFragment.clientsStore.registerClient(mockClient); + + // Should accept resource without fragment + await expect(providerWithFragment.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + }); + + it('should reject token exchange without resource parameter', async () => { + // First authorize with resource + mockResponse.redirect = jest.fn(); + await strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const authCode = url.searchParams.get('code')!; + + await expect(strictProvider.exchangeAuthorizationCode( + mockClient, + authCode, + undefined, + undefined + // resource is missing + )).rejects.toThrow(InvalidRequestError); + }); + + it('should reject refresh token without resource parameter', async () => { + await expect(strictProvider.exchangeRefreshToken( + mockClient, + 'refresh-token', + undefined + // resource is missing + )).rejects.toThrow(InvalidRequestError); + }); + }); + + describe('with server URL validation disabled (warning mode)', () => { + let warnProvider: DemoInMemoryAuthProvider; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + warnProvider = new DemoInMemoryAuthProvider(); // No config = warnings enabled + + warnProvider.clientsStore.registerClient(mockClient); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should log warning when resource is missing from authorization', async () => { + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + // resource is missing + }, mockResponse as Response); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('test-client is missing the resource parameter') + ); + }); + + it('should not log warning when resource is present', async () => { + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should log warning when resource is missing from token exchange', async () => { + // First authorize without resource + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const authCode = url.searchParams.get('code')!; + + await warnProvider.exchangeAuthorizationCode( + mockClient, + authCode, + undefined, + undefined + // resource is missing + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('test-client is missing the resource parameter') + ); + }); + }); + }); }); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 66583e490..2f0e35392 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -5,7 +5,8 @@ import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../sh import express, { Request, Response } from "express"; import { AuthInfo } from '../../server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { InvalidTargetError } from '../../server/auth/errors.js'; +import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; +import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; interface ExtendedClientInformation extends OAuthClientInformationFull { @@ -48,19 +49,79 @@ interface ExtendedAuthInfo extends AuthInfo { type?: string; } +/** + * Configuration options for the demo OAuth provider + */ +export interface DemoOAuthProviderConfig { + /** + * The canonical URL of this MCP server. When provided, the provider will validate + * that the resource parameter in OAuth requests matches this URL. + * + * This should be the full URL that clients use to connect to this server, + * without any fragment component (e.g., "https://api.example.com/mcp"). + * + * Required when validateResourceMatchesServer is true. + */ + serverUrl?: string; + + /** + * If true, validates that the resource parameter matches the configured serverUrl. + * + * When enabled: + * - serverUrl must be configured (throws error if not) + * - resource parameter is required on all requests + * - resource must exactly match serverUrl (after fragment removal) + * - requests without resource parameter will be rejected with invalid_request error + * - requests with non-matching resource will be rejected with invalid_target error + * + * When disabled: + * - warnings are logged when resource parameter is missing (for migration tracking) + * + * @default false + */ + validateResourceMatchesServer?: boolean; +} + export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); private tokens = new Map(); + private config?: DemoOAuthProviderConfig; + + constructor(config?: DemoOAuthProviderConfig) { + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + this.config = config; + } async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, res: Response ): Promise { - // Validate resource parameter if provided + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!params.resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + // Remove fragment from server URL if present (though it shouldn't have one) + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (params.resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${params.resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!params.resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Authorization request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + + // Additional validation: check if client is allowed to access the resource if (params.resource) { await this.validateResource(client, params.resource); } @@ -116,6 +177,24 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + // Validate that the resource matches what was authorized if (resource !== codeData.params.resource) { throw new InvalidTargetError('Resource parameter does not match the authorized resource'); @@ -154,7 +233,25 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _scopes?: string[], resource?: string ): Promise { - // Validate resource parameter if provided + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + + // Additional validation: check if client is allowed to access the resource if (resource) { await this.validateResource(client, resource); } @@ -204,13 +301,13 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { +export const setupAuthServer = (authServerUrl: URL, config?: DemoOAuthProviderConfig): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(); + const provider = new DemoInMemoryAuthProvider(config); const authApp = express(); authApp.use(express.json()); // For introspection requests diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920f..65b6263ec 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -282,7 +282,12 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl); + // Configure the demo auth provider to validate resources match this server + const demoProviderConfig = { + serverUrl: mcpServerUrl.href, + validateResourceMatchesServer: false // Set to true to enable strict validation + }; + const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, demoProviderConfig); const tokenVerifier = { verifyAccessToken: async (token: string) => { diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts deleted file mode 100644 index f0736da21..000000000 --- a/src/server/auth/handlers/authorize.config.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { authorizationHandler } from "./authorize.js"; -import { OAuthServerProvider } from "../provider.js"; -import { OAuthServerConfig } from "../types.js"; - -describe("Authorization handler with config", () => { - let app: express.Application; - let mockProvider: jest.Mocked; - - beforeEach(() => { - app = express(); - - const mockClientsStore = { - getClient: jest.fn(), - registerClient: jest.fn(), - }; - - mockProvider = { - clientsStore: mockClientsStore, - authorize: jest.fn(), - exchangeAuthorizationCode: jest.fn(), - exchangeRefreshToken: jest.fn(), - challengeForAuthorizationCode: jest.fn(), - verifyAccessToken: jest.fn(), - } as jest.Mocked; - }); - - describe("validateResourceMatchesServer configuration", () => { - it("should throw error when validateResourceMatchesServer is true but serverUrl is not set", () => { - const invalidConfig: OAuthServerConfig = { - validateResourceMatchesServer: true - // serverUrl is missing - }; - - expect(() => { - authorizationHandler({ - provider: mockProvider, - config: invalidConfig - }); - }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); - }); - }); - - describe("server URL validation (validateResourceMatchesServer: true)", () => { - const serverValidationConfig: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp", - validateResourceMatchesServer: true - }; - - beforeEach(() => { - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: serverValidationConfig - })); - }); - - it("should reject requests without resource parameter", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_request"); - expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); - }); - - it("should accept requests with resource parameter", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - expect(mockProvider.authorize).toHaveBeenCalledWith( - mockClient, - expect.objectContaining({ - resource: "https://api.example.com/mcp" - }), - expect.any(Object) - ); - }); - }); - - describe("warning mode (default behavior)", () => { - const warnConfig: OAuthServerConfig = { - // No configuration needed - warnings are always enabled by default - }; - - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: warnConfig - })); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - - it("should log warning when resource is missing", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("test-client is missing the resource parameter") - ); - }); - - it("should not log warning when resource is present", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); - - // Note: No silent mode test anymore - warnings are always enabled - - describe("server URL validation (validateResourceMatchesServer: true)", () => { - const serverValidationConfig: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp", - validateResourceMatchesServer: true - }; - - beforeEach(() => { - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: serverValidationConfig - })); - }); - - it("should accept requests when resource matches server URL", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - }); - - it("should reject requests when resource does not match server URL", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://different.api.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_target"); - expect(response.headers.location).toContain("does+not+match+this+server"); - }); - - it("should reject requests without resource parameter when validation is enabled", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_request"); - expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); - }); - - it("should handle server URL with fragment correctly", async () => { - // Reconfigure with a server URL that has a fragment (though it shouldn't) - const configWithFragment: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp#fragment", - validateResourceMatchesServer: true - }; - - app = express(); - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: configWithFragment - })); - - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" // No fragment - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - }); - }); -}); \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 946c46c9d..f6c862aca 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -8,13 +8,10 @@ import { InvalidRequestError, InvalidClientError, InvalidScopeError, - InvalidTargetError, ServerError, TooManyRequestsError, OAuthError } from "../errors.js"; -import { OAuthServerConfig } from "../types.js"; -import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -23,10 +20,6 @@ export type AuthorizationHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; - /** - * OAuth server configuration options - */ - config?: OAuthServerConfig; }; // Parameters that must be validated in order to issue redirects. @@ -45,12 +38,7 @@ const RequestAuthorizationParamsSchema = z.object({ resource: z.string().url().optional(), }); -export function authorizationHandler({ provider, rateLimit: rateLimitConfig, config }: AuthorizationHandlerOptions): RequestHandler { - // Validate configuration - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); - } - +export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { // Create a router to apply middleware const router = express.Router(); router.use(allowedMethods(["GET", "POST"])); @@ -131,26 +119,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig, con const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Authorization request from client ${client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it // Validate scopes let requestedScopes: string[] = []; diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 7af42d7ad..92fe99218 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -12,11 +12,8 @@ import { UnsupportedGrantTypeError, ServerError, TooManyRequestsError, - OAuthError, - InvalidTargetError + OAuthError } from "../errors.js"; -import { OAuthServerConfig } from "../types.js"; -import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -25,10 +22,6 @@ export type TokenHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; - /** - * OAuth server configuration options - */ - config?: OAuthServerConfig; }; const TokenRequestSchema = z.object({ @@ -48,12 +41,7 @@ const RefreshTokenGrantSchema = z.object({ resource: z.string().url().optional(), }); -export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: TokenHandlerOptions): RequestHandler { - // Validate configuration - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); - } - +export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -105,25 +93,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T const { code, code_verifier, redirect_uri, resource } = parseResult.data; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -156,25 +127,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T const { refresh_token, scope, resource } = parseResult.data; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it const scopes = scope?.split(" "); const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 33ba3f865..c25c2b602 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -27,37 +27,4 @@ export interface AuthInfo { * This field should be used for any additional data that needs to be attached to the auth info. */ extra?: Record; -} - -/** - * Configuration options for OAuth server behavior - */ -export interface OAuthServerConfig { - /** - * The canonical URL of this MCP server. When provided, the server will validate - * that the resource parameter in OAuth requests matches this URL. - * - * This should be the full URL that clients use to connect to this server, - * without any fragment component (e.g., "https://api.example.com/mcp"). - * - * Required when validateResourceMatchesServer is true. - */ - serverUrl?: string; - - /** - * If true, validates that the resource parameter matches the configured serverUrl. - * - * When enabled: - * - serverUrl must be configured (throws error if not) - * - resource parameter is required on all requests - * - resource must exactly match serverUrl (after fragment removal) - * - requests without resource parameter will be rejected with invalid_request error - * - requests with non-matching resource will be rejected with invalid_target error - * - * When disabled: - * - warnings are logged when resource parameter is missing (for migration tracking) - * - * @default false - */ - validateResourceMatchesServer?: boolean; } \ No newline at end of file From 5a8af45c09a9b75da06e372817ed9c9b89b3ee10 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 16 Jun 2025 16:34:46 -0400 Subject: [PATCH 027/208] 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 ebe31920f..dfc1f1979 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 e542ec1989636987867ea082adb0e0b8eab88c91 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 21:38:00 +0100 Subject: [PATCH 028/208] docs: update PR description to clarify server-side validation is in demo provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify that core server handlers only pass through resource parameter - Emphasize that server URL validation is demonstrated in the demo provider - Update issue references to show #592 is fixed, #635 is related - Update examples to show DemoOAuthProviderConfig usage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.local.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++ PR-DESCRIPTION.md | 111 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 CLAUDE.local.md create mode 100644 PR-DESCRIPTION.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 000000000..9a43ac7ca --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,125 @@ +# RFC 8707 Resource Indicators Implementation for MCP TypeScript SDK + +This PR implements RFC 8707 (Resource Indicators for OAuth 2.0) in the MCP TypeScript SDK, addressing critical security vulnerabilities and adding resource-scoped authorization support. + +## Issues Addressed + +- **Fixes #592**: Implements client-side resource parameter passing to prevent token confusion attacks +- **Related to #635**: Demonstrates server-side RFC 8707 validation in the demo OAuth provider + +## Overview + +This implementation adds resource parameter support to MCP's OAuth flow, explicitly binding access tokens to specific MCP servers. This prevents malicious servers from stealing OAuth tokens intended for other services. + +## Implementation Summary + +### 1. Core Auth Infrastructure + +#### Client-Side Changes (`src/client/`) +- **auth.ts**: Added resource parameter support to authorization and token exchange flows +- **Transport layers** (sse.ts, streamableHttp.ts): Automatically extract canonical server URIs for resource parameter + +#### Server-Side Changes (`src/server/auth/`) +- **handlers/**: Updated authorize and token handlers to accept and pass through resource parameters +- **provider.ts**: Extended provider interface to support resource parameters +- **errors.ts**: Added `InvalidTargetError` for RFC 8707 compliance + +#### Shared Utilities (`src/shared/`) +- **auth-utils.ts**: Created utilities for resource URI validation and canonicalization +- **auth.ts**: Updated OAuth schemas to include resource parameter + +### 2. Demo OAuth Provider Enhancement (`src/examples/server/`) + +The demo provider demonstrates how to implement RFC 8707 validation: +- Optional resource validation during authorization (via `DemoOAuthProviderConfig`) +- Resource consistency checks during token exchange +- Resource information included in token introspection +- Support for validating resources against a configured server URL +- Client-specific resource allowlists + +### 3. Resource URI Requirements + +Resource URIs follow RFC 8707 requirements: +- **MUST NOT** include fragments (automatically removed by the SDK) +- The SDK preserves all other URL components (scheme, host, port, path, query) exactly as provided +- No additional canonicalization is performed to maintain compatibility with various server configurations + +## Client vs Server Implementation Differences + +### Client-Side Implementation +- **Automatic resource extraction**: Transports automatically determine the server URI for resource parameter +- **Transparent integration**: Resource parameter is added without changing existing auth APIs +- **Fragment removal**: Fragments are automatically removed from URIs per RFC 8707 +- **Focus**: Ensuring resource parameter is correctly included in all OAuth requests + +### Server-Side Implementation +- **Core handlers**: Pass through resource parameter without validation +- **Demo provider**: Shows how to implement resource validation +- **Provider flexibility**: Auth providers decide how to enforce resource restrictions +- **Backward compatibility**: Servers work with clients that don't send resource parameter +- **Focus**: Demonstrating best practices for resource validation + +## Testing Approach Differences + +### Client-Side Tests +- **Unit tests**: Verify resource parameter is included in auth URLs and token requests +- **Validation tests**: Ensure resource URI validation and canonicalization work correctly +- **Integration focus**: Test interaction between transport layer and auth module + +### Server-Side Tests +- **Handler tests**: Verify resource parameter is accepted and passed to providers +- **Demo provider tests**: Comprehensive tests for server URL validation and client-specific allowlists +- **Security tests**: Verify invalid resources are rejected with proper errors +- **Configuration tests**: Test various demo provider configurations +- **End-to-end tests**: Full OAuth flow with resource validation + +## Security Considerations + +1. **Token Binding**: Tokens are explicitly bound to the resource they're intended for +2. **Validation**: Both client and server validate resource URIs to prevent attacks +3. **Consistency**: Resource must match between authorization and token exchange +4. **Introspection**: Resource information is included in token introspection responses + +## Migration Guide + +### For Client Developers +No changes required - the SDK automatically includes the resource parameter based on the server URL. + +### For Server Developers +1. Core server handlers automatically pass through the resource parameter +2. Custom auth providers can implement resource validation as shown in the demo provider +3. Demo provider configuration options: + - `serverUrl`: The canonical URL of the MCP server + - `validateResourceMatchesServer`: Enable strict resource validation +4. Return `invalid_target` error for unauthorized resources +5. Include resource in token introspection responses + +## Example Usage + +```typescript +// Client automatically includes resource parameter +const transport = new StreamableHttpClientTransport( + 'https://api.example.com/mcp', + authProvider +); + +// Demo provider configuration with resource validation +const demoProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true // Makes resource required and validates it +}; +const provider = new DemoInMemoryAuthProvider(demoProviderConfig); +``` + +## Future Enhancements + +1. Add support for multiple resource parameters (RFC 8707 allows arrays) +2. Implement resource-specific scope restrictions +3. Add telemetry for resource parameter usage +4. Create migration tooling for existing deployments + +## References + +- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) +- [MCP Issue #544 - Security Vulnerability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544) \ No newline at end of file diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md new file mode 100644 index 000000000..b4e48cbd2 --- /dev/null +++ b/PR-DESCRIPTION.md @@ -0,0 +1,111 @@ +# RFC 8707 Resource Indicators Implementation + + +Implements RFC 8707 (Resource Indicators for OAuth 2.0) support in the MCP TypeScript SDK. This adds the `resource` parameter to OAuth authorization and token exchange flows, allowing access tokens to be explicitly bound to specific MCP servers. The implementation includes automatic resource extraction in client transports, server-side parameter passing, and demonstrates resource validation in the demo OAuth provider. + +(Fixes #592, Related to #635) + +## Motivation and Context + +This change addresses critical security vulnerabilities identified in https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544. Without resource indicators, OAuth tokens intended for one MCP server could be stolen and misused by malicious servers. RFC 8707 prevents these token confusion attacks by explicitly binding tokens to their intended resources. + +Key problems solved: +- Prevents token theft/confusion attacks where a malicious MCP server steals tokens meant for other services +- Enables fine-grained access control by restricting OAuth clients to specific resources +- Improves security posture by following OAuth 2.0 Security Best Current Practice recommendations + +## How Has This Been Tested? + +Comprehensive test coverage has been added: + +**Client-side testing:** +- Unit tests verify resource parameter inclusion in authorization URLs and token requests (512 new lines in auth.test.ts) +- Transport layer tests ensure automatic resource extraction works correctly +- Fragment removal and URI validation tests + +**Server-side testing:** +- Authorization handler tests for resource parameter acceptance +- Token handler tests for resource parameter passing +- Demo provider tests for resource restrictions and validation (including server URL validation) +- Proxy provider tests for resource parameter forwarding + +**Integration testing:** +- End-to-end OAuth flow with resource validation +- Resource validation example demonstrating real-world usage patterns +- Tests for both clients with and without resource restrictions + +## Breaking Changes + +While the change is breaking at a protocol level, it should not require code changes from SDK users (just SDK version bumping). + +- **Client developers**: No code changes required. The SDK automatically extracts and includes the resource parameter from the server URL +- **Server developers**: The core server handlers now pass through the resource parameter. Resource validation is demonstrated in the demo provider but remains optional for custom providers +- **Auth providers**: Should be updated to accept and handle the resource parameter. The demo provider shows how to implement server URL validation and client-specific resource restrictions + +## Types of changes + +- [x] Bug fix (non-breaking change which fixes an issue) +- [x] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update + +## Checklist + +- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) +- [x] My code follows the repository's style guidelines +- [x] New and existing tests pass locally +- [x] I have added appropriate error handling +- [x] I have added or updated documentation as needed + +## Additional context + + +### Server-Side Implementation Approach + +The core server implementation focuses on passing through the resource parameter without enforcing validation, maintaining backward compatibility and flexibility. The demo provider demonstrates how to implement RFC 8707 validation: + +1. **Core Server**: Handlers accept and forward the resource parameter to auth providers without validation +2. **Demo Provider**: Shows how to implement comprehensive resource validation including: + - Server URL matching validation (configurable via `DemoOAuthProviderConfig`) + - Client-specific resource allowlists + - Warning logs for missing resource parameters + - Consistent resource validation between authorization and token exchange + +This separation allows: +- Existing providers to continue working without modification +- New providers to implement validation according to their security requirements +- Gradual migration to RFC 8707 compliance +- Different validation strategies for different deployment scenarios + +### Implementation Approach + +Resource URIs are used as-is with only fragment removal (per RFC requirement). This allows having different MCP servers under different subpaths (even w/ different query URLs) w/o sharing spilling their resource authorization to each other (to allow a variety of MCP server federation use cases). + +### Key Components Added +1. **Shared utilities** (`auth-utils.ts`): Resource URI handling and validation +2. **Client auth** modifications: Resource parameter support in authorization/token flows +3. **Transport layers**: Automatic resource extraction from server URLs +4. **Server handlers**: Resource parameter acceptance and forwarding +5. **Demo provider**: Full RFC 8707 implementation with resource validation +6. **Error handling**: New `InvalidTargetError` for RFC 8707 compliance + +### Example Usage +```typescript +// Client-side (automatic) +const transport = new StreamableHttpClientTransport( + 'https://api.example.com/mcp', + authProvider +); + +// Demo provider configuration with validation +const demoProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true // Makes resource required and validates it matches serverUrl +}; +const provider = new DemoInMemoryAuthProvider(demoProviderConfig); +``` + +### References +- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- Fixes #592: OAuth token confusion vulnerability - client-side resource parameter support +- Related to #635: Demonstrates server-side RFC 8707 validation in demo provider \ No newline at end of file From 6656d23d84cd9058dc8f8cfdd23a1a343a0d1a92 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 22:56:55 +0100 Subject: [PATCH 029/208] Simplify demo in-memory oauth provider Co-Authored-By: Claude --- .../server/demoInMemoryOAuthProvider.ts | 148 ++++-------------- src/server/auth/types.ts | 6 + 2 files changed, 33 insertions(+), 121 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 2f0e35392..3672c3e0a 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -9,44 +9,17 @@ import { InvalidTargetError, InvalidRequestError } from '../../server/auth/error import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; -interface ExtendedClientInformation extends OAuthClientInformationFull { - allowed_resources?: string[]; -} - export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); + private clients = new Map(); async getClient(clientId: string) { return this.clients.get(clientId); } - async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) { + async registerClient(clientMetadata: OAuthClientInformationFull) { this.clients.set(clientMetadata.client_id, clientMetadata); return clientMetadata; } - - /** - * Demo method to set allowed resources for a client - */ - setAllowedResources(clientId: string, resources: string[]) { - const client = this.clients.get(clientId); - if (client) { - client.allowed_resources = resources; - } - } -} - -/** - * 🚨 DEMO ONLY - NOT FOR PRODUCTION - * - * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, - * for example: - * - Persistent token storage - * - Rate limiting - */ -interface ExtendedAuthInfo extends AuthInfo { - resource?: string; - type?: string; } /** @@ -87,7 +60,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { private codes = new Map(); - private tokens = new Map(); + private tokens = new Map(); private config?: DemoOAuthProviderConfig; constructor(config?: DemoOAuthProviderConfig) { @@ -102,29 +75,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response ): Promise { - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!params.resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (params.resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${params.resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!params.resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Authorization request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Additional validation: check if client is allowed to access the resource - if (params.resource) { - await this.validateResource(client, params.resource); - } + await this.validateResource(params.resource); const code = randomUUID(); @@ -164,9 +115,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { authorizationCode: string, // Note: code verifier is checked in token.ts by default // it's unused here for that reason. - _codeVerifier?: string, - _redirectUri?: string, - resource?: string + _codeVerifier?: string ): Promise { const codeData = this.codes.get(authorizationCode); if (!codeData) { @@ -177,44 +126,18 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Validate that the resource matches what was authorized - if (resource !== codeData.params.resource) { - throw new InvalidTargetError('Resource parameter does not match the authorized resource'); - } - - // If resource was specified during authorization, validate it's still allowed - if (codeData.params.resource) { - await this.validateResource(client, codeData.params.resource); - } + await this.validateResource(codeData.params.resource); this.codes.delete(authorizationCode); const token = randomUUID(); - const tokenData: ExtendedAuthInfo = { + const tokenData = { token, clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour + resource: codeData.params.resource, type: 'access', - resource: codeData.params.resource }; this.tokens.set(token, tokenData); @@ -233,28 +156,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _scopes?: string[], resource?: string ): Promise { - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Additional validation: check if client is allowed to access the resource - if (resource) { - await this.validateResource(client, resource); - } throw new Error('Refresh tokens not implemented for example demo'); } @@ -263,12 +164,14 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { throw new Error('Invalid or expired token'); } + await this.validateResource(tokenData.resource); return { token, clientId: tokenData.clientId, scopes: tokenData.scopes, expiresAt: Math.floor(tokenData.expiresAt / 1000), + resource: tokenData.resource, }; } @@ -276,26 +179,29 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { * Validates that the client is allowed to access the requested resource. * In a real implementation, this would check against a database or configuration. */ - private async validateResource(client: OAuthClientInformationFull, resource: string): Promise { - const extendedClient = client as ExtendedClientInformation; - - // If no resources are configured, allow any resource (for demo purposes) - if (!extendedClient.allowed_resources) { - return; - } - - // Check if the requested resource is in the allowed list - if (!extendedClient.allowed_resources.includes(resource)) { - throw new InvalidTargetError( - `Client is not authorized to access resource: ${resource}` - ); + private async validateResource(resource?: string): Promise { + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token refresh request is missing the resource parameter. Consider migrating to RFC 8707.`); } } /** * Get token details including resource information (for demo introspection endpoint) */ - getTokenDetails(token: string): ExtendedAuthInfo | undefined { + getTokenDetails(token: string): AuthInfo | undefined { return this.tokens.get(token); } } diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b602..bf1a257b2 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -22,6 +22,12 @@ export interface AuthInfo { */ expiresAt?: number; + /** + * The RFC 8707 resource server identifier for which this token is valid. + * If set, this MUST match the MCP server's resource identifier (minus hash fragment). + */ + resource?: string; + /** * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. From 02ce81b90c4ebe55e13b064051950f5fe2951daa Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 23:10:27 +0100 Subject: [PATCH 030/208] simplify diff --- .../server/demoInMemoryOAuthProvider.ts | 82 +++---------------- src/server/auth/errors.ts | 10 --- src/server/auth/provider.ts | 2 +- src/server/auth/types.ts | 2 +- src/shared/auth-utils.ts | 29 ++----- 5 files changed, 20 insertions(+), 105 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 3672c3e0a..5de0fb904 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -5,7 +5,6 @@ import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../sh import express, { Request, Response } from "express"; import { AuthInfo } from '../../server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; @@ -22,52 +21,21 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { } } -/** - * Configuration options for the demo OAuth provider - */ -export interface DemoOAuthProviderConfig { - /** - * The canonical URL of this MCP server. When provided, the provider will validate - * that the resource parameter in OAuth requests matches this URL. - * - * This should be the full URL that clients use to connect to this server, - * without any fragment component (e.g., "https://api.example.com/mcp"). - * - * Required when validateResourceMatchesServer is true. - */ - serverUrl?: string; - - /** - * If true, validates that the resource parameter matches the configured serverUrl. - * - * When enabled: - * - serverUrl must be configured (throws error if not) - * - resource parameter is required on all requests - * - resource must exactly match serverUrl (after fragment removal) - * - requests without resource parameter will be rejected with invalid_request error - * - requests with non-matching resource will be rejected with invalid_target error - * - * When disabled: - * - warnings are logged when resource parameter is missing (for migration tracking) - * - * @default false - */ - validateResourceMatchesServer?: boolean; -} - export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); private tokens = new Map(); - private config?: DemoOAuthProviderConfig; - - constructor(config?: DemoOAuthProviderConfig) { - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + private validateResource?: (resource?: URL) => boolean; + + constructor(mcpServerUrl?: URL) { + if (mcpServerUrl) { + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + this.validateResource = (resource?: URL) => { + return !resource || resource.toString() !== expectedResource.toString(); + }; } - this.config = config; } async authorize( @@ -75,8 +43,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response ): Promise { - await this.validateResource(params.resource); - const code = randomUUID(); const searchParams = new URLSearchParams({ @@ -126,7 +92,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } - await this.validateResource(codeData.params.resource); + if (this.validateResource && !this.validateResource(codeData.params.resource)) { + throw new Error('Invalid resource'); + } this.codes.delete(authorizationCode); const token = randomUUID(); @@ -164,7 +132,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { throw new Error('Invalid or expired token'); } - await this.validateResource(tokenData.resource); return { token, @@ -175,29 +142,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { }; } - /** - * Validates that the client is allowed to access the requested resource. - * In a real implementation, this would check against a database or configuration. - */ - private async validateResource(resource?: string): Promise { - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token refresh request is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } - /** * Get token details including resource information (for demo introspection endpoint) */ @@ -207,13 +151,13 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = (authServerUrl: URL, config?: DemoOAuthProviderConfig): OAuthMetadata => { +export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(config); + const provider = new DemoInMemoryAuthProvider(mcpServerUrl); const authApp = express(); authApp.use(express.json()); // For introspection requests diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 5c001bcda..428199ce8 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -189,13 +189,3 @@ export class InsufficientScopeError extends OAuthError { super("insufficient_scope", message, errorUri); } } - -/** - * Invalid target error - The requested resource is invalid, unknown, or malformed. - * (RFC 8707 - Resource Indicators for OAuth 2.0) - */ -export class InvalidTargetError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_target", message, errorUri); - } -} diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 256984166..93a56a099 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,7 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; - resource?: string; + resource?: URL; }; /** diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index bf1a257b2..0189e9ed8 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -26,7 +26,7 @@ export interface AuthInfo { * The RFC 8707 resource server identifier for which this token is valid. * If set, this MUST match the MCP server's resource identifier (minus hash fragment). */ - resource?: string; + resource?: URL; /** * Additional data associated with the token. diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index e69d821da..086d812f6 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -1,5 +1,5 @@ /** - * Utilities for handling OAuth resource URIs according to RFC 8707. + * Utilities for handling OAuth resource URIs. */ /** @@ -7,27 +7,8 @@ * 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: string): string { - const hashIndex = url.indexOf('#'); - return hashIndex === -1 ? url : url.substring(0, hashIndex); +export function resourceUrlFromServerUrl(url: URL): URL { + const resourceURL = new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; } - -/** - * Validates a resource URI according to RFC 8707 requirements. - * @param resourceUri The resource URI to validate - * @throws Error if the URI contains a fragment - */ -export function validateResourceUri(resourceUri: string): void { - if (resourceUri.includes('#')) { - throw new Error(`Invalid resource URI: ${resourceUri} - must not contain a fragment`); - } -} - -/** - * Extracts resource URI from server URL by removing fragment. - * @param serverUrl The server URL to extract from - * @returns The resource URI without fragment - */ -export function extractResourceUri(serverUrl: string | URL): string { - return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); -} \ No newline at end of file From 36f338ae23503ce837f12c0f166e09de50885d16 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 23:39:51 +0100 Subject: [PATCH 031/208] Update demoInMemoryOAuthProvider.ts --- src/examples/server/demoInMemoryOAuthProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 5de0fb904..9fcb25176 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -29,7 +29,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { private tokens = new Map(); private validateResource?: (resource?: URL) => boolean; - constructor(mcpServerUrl?: URL) { + constructor({mcpServerUrl}: {mcpServerUrl?: URL} = {}) { if (mcpServerUrl) { const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); this.validateResource = (resource?: URL) => { @@ -157,7 +157,7 @@ export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMet // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(mcpServerUrl); + const provider = new DemoInMemoryAuthProvider({mcpServerUrl}); const authApp = express(); authApp.use(express.json()); // For introspection requests From 224a2e242d956f30b20fdeb370c8b9958e321f0c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:18:33 +0100 Subject: [PATCH 032/208] update resource to be a url --- src/client/auth.ts | 18 ++-- .../server/demoInMemoryOAuthProvider.ts | 2 +- src/server/auth/handlers/authorize.test.ts | 6 +- src/server/auth/handlers/authorize.ts | 2 +- src/server/auth/provider.ts | 4 +- .../auth/providers/proxyProvider.test.ts | 2 +- src/server/auth/providers/proxyProvider.ts | 6 +- src/shared/auth-utils.test.ts | 85 +++---------------- 8 files changed, 33 insertions(+), 92 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 28188b7c0..e465ea3be 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -100,12 +100,12 @@ export async function auth( authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL; - resource?: string }): Promise { + resource?: URL }): Promise { // Remove fragment from resource parameter if provided - let canonicalResource: string | undefined; + let canonicalResource: URL | undefined; if (resource) { - canonicalResource = resourceUrlFromServerUrl(resource); + canonicalResource = resourceUrlFromServerUrl(new URL(resource)); } let authorizationServerUrl = serverUrl; @@ -329,7 +329,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: string; + resource?: URL; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -380,7 +380,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set("resource", resource); + authorizationUrl.searchParams.set("resource", resource.href); } return { authorizationUrl, codeVerifier }; @@ -404,7 +404,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: string; + resource?: URL; }, ): Promise { const grantType = "authorization_code"; @@ -439,7 +439,7 @@ export async function exchangeAuthorization( } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(tokenUrl, { @@ -471,7 +471,7 @@ export async function refreshAuthorization( metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; - resource?: string; + resource?: URL; }, ): Promise { const grantType = "refresh_token"; @@ -504,7 +504,7 @@ export async function refreshAuthorization( } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(tokenUrl, { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 9fcb25176..316d1f8a4 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -122,7 +122,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { client: OAuthClientInformationFull, _refreshToken: string, _scopes?: string[], - resource?: string + resource?: URL ): Promise { throw new Error('Refresh tokens not implemented for example demo'); } diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 20a2af897..2742d1e55 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -295,7 +295,7 @@ describe('Authorization Handler', () => { expect(mockProviderWithResource).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api.example.com/resource', + resource: new URL('https://api.example.com/resource'), redirectUri: 'https://example.com/callback', codeChallenge: 'challenge123' }), @@ -365,7 +365,7 @@ describe('Authorization Handler', () => { expect(mockProviderWithResources).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api1.example.com/resource', + resource: new URL('https://api1.example.com/resource'), state: 'test-state' }), expect.any(Object) @@ -391,7 +391,7 @@ describe('Authorization Handler', () => { expect(mockProviderPost).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api.example.com/resource' + resource: new URL('https://api.example.com/resource') }), expect.any(Object) ); diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index f6c862aca..17c88b45a 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -142,7 +142,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, - resource, + resource: resource ? new URL(resource) : undefined, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 93a56a099..18beb2166 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -42,13 +42,13 @@ export interface OAuthServerProvider { authorizationCode: string, codeVerifier?: string, redirectUri?: string, - resource?: string + resource?: URL ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: string): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; /** * Verifies an access token and returns information about it. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index b652390b0..75dc1a15a 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -112,7 +112,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: 'test-challenge', state: 'test-state', scopes: ['read', 'write'], - resource: 'https://api.example.com/resource' + resource: new URL('https://api.example.com/resource') }, mockResponse ); diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 7f8b8d3df..4c8074448 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,7 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); - if (params.resource) searchParams.set("resource", params.resource); + if (params.resource) searchParams.set("resource", params.resource.href); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -154,7 +154,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { authorizationCode: string, codeVerifier?: string, redirectUri?: string, - resource?: string + resource?: URL ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -199,7 +199,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], - resource?: string + resource?: URL ): Promise { const params = new URLSearchParams({ diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index b95714081..c35bb1228 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,89 +1,30 @@ -import { validateResourceUri, extractResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; +import { resourceUrlFromServerUrl } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { it('should remove fragments', () => { - expect(resourceUrlFromServerUrl('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(resourceUrlFromServerUrl('https://example.com#fragment')).toBe('https://example.com'); - expect(resourceUrlFromServerUrl('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe('https://example.com/path?query=1'); }); it('should return URL unchanged if no fragment', () => { - expect(resourceUrlFromServerUrl('https://example.com')).toBe('https://example.com'); - expect(resourceUrlFromServerUrl('https://example.com/path')).toBe('https://example.com/path'); - expect(resourceUrlFromServerUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); }); it('should keep everything else unchanged', () => { // Case sensitivity preserved - expect(resourceUrlFromServerUrl('HTTPS://EXAMPLE.COM/PATH')).toBe('HTTPS://EXAMPLE.COM/PATH'); + expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); // Ports preserved - expect(resourceUrlFromServerUrl('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(resourceUrlFromServerUrl('https://example.com:8080/path')).toBe('https://example.com:8080/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); // Query parameters preserved - expect(resourceUrlFromServerUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com?foo=bar&baz=qux'); + expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe('https://example.com/?foo=bar&baz=qux'); // Trailing slashes preserved - expect(resourceUrlFromServerUrl('https://example.com/')).toBe('https://example.com/'); - expect(resourceUrlFromServerUrl('https://example.com/path/')).toBe('https://example.com/path/'); - }); - }); - - - describe('validateResourceUri', () => { - it('should accept valid resource URIs without fragments', () => { - expect(() => validateResourceUri('https://example.com')).not.toThrow(); - expect(() => validateResourceUri('https://example.com/path')).not.toThrow(); - expect(() => validateResourceUri('http://example.com:8080')).not.toThrow(); - expect(() => validateResourceUri('https://example.com?query=1')).not.toThrow(); - expect(() => validateResourceUri('ftp://example.com')).not.toThrow(); // Only fragment check now - }); - - it('should reject URIs with fragments', () => { - expect(() => validateResourceUri('https://example.com#fragment')).toThrow('must not contain a fragment'); - expect(() => validateResourceUri('https://example.com/path#section')).toThrow('must not contain a fragment'); - expect(() => validateResourceUri('https://example.com?query=1#anchor')).toThrow('must not contain a fragment'); - }); - - it('should accept any URI without fragment', () => { - // These are all valid now since we only check for fragments - expect(() => validateResourceUri('//example.com')).not.toThrow(); - expect(() => validateResourceUri('https://user:pass@example.com')).not.toThrow(); - expect(() => validateResourceUri('/path')).not.toThrow(); - expect(() => validateResourceUri('path')).not.toThrow(); - }); - }); - - describe('extractResourceUri', () => { - it('should remove fragments from URLs', () => { - expect(extractResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(extractResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); - }); - - it('should handle URL object', () => { - const url = new URL('https://example.com:8443/path?query=1#fragment'); - expect(extractResourceUri(url)).toBe('https://example.com:8443/path?query=1'); - }); - - it('should keep everything else unchanged', () => { - // Preserves case - expect(extractResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); - // Preserves all ports - expect(extractResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(extractResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); - // Preserves query parameters - expect(extractResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); - // Preserves trailing slashes - expect(extractResourceUri('https://example.com/')).toBe('https://example.com/'); - expect(extractResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); - }); - - it('should distinguish between different paths on same domain', () => { - // This is the key test for the security concern mentioned - const app1 = extractResourceUri('https://api.example.com/mcp-server-1'); - const app2 = extractResourceUri('https://api.example.com/mcp-server-2'); - expect(app1).not.toBe(app2); - expect(app1).toBe('https://api.example.com/mcp-server-1'); - expect(app2).toBe('https://api.example.com/mcp-server-2'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); }); }); }); \ No newline at end of file From 551a43942e412f43aee13de67ee7a796f5763831 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:33:27 +0100 Subject: [PATCH 033/208] use URL for resource throughout --- src/client/auth.test.ts | 24 +++++++++---------- src/client/sse.ts | 8 +++---- src/client/streamableHttp.ts | 8 +++---- src/server/auth/handlers/token.test.ts | 8 +++---- src/server/auth/handlers/token.ts | 4 ++-- .../auth/providers/proxyProvider.test.ts | 6 ++--- src/server/auth/providers/proxyProvider.ts | 4 ++-- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9a0674057..44516130a 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -347,7 +347,7 @@ describe("OAuth Authorization", () => { { clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -526,7 +526,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -650,7 +650,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -939,7 +939,7 @@ describe("OAuth Authorization", () => { // Call the auth function with a resource that has a fragment const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server#fragment", + resource: new URL("https://api.example.com/mcp-server#fragment"), }); expect(result).toBe("REDIRECT"); @@ -988,7 +988,7 @@ describe("OAuth Authorization", () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("REDIRECT"); @@ -1050,7 +1050,7 @@ describe("OAuth Authorization", () => { const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", authorizationCode: "auth-code-123", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1112,7 +1112,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1161,7 +1161,7 @@ describe("OAuth Authorization", () => { // Call auth with empty resource parameter const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "", + resource: undefined, }); expect(result).toBe("REDIRECT"); @@ -1204,7 +1204,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing multiple # symbols const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server#fragment#another", + resource: new URL("https://api.example.com/mcp-server#fragment#another"), }); expect(result).toBe("REDIRECT"); @@ -1249,7 +1249,7 @@ describe("OAuth Authorization", () => { // multiple MCP servers on the same domain const result1 = await auth(mockProvider, { serverUrl: "https://api.example.com", - resource: "https://api.example.com/mcp-server-1/v1", + resource: new URL("https://api.example.com/mcp-server-1/v1"), }); expect(result1).toBe("REDIRECT"); @@ -1264,7 +1264,7 @@ describe("OAuth Authorization", () => { // Test with different path on same domain const result2 = await auth(mockProvider, { serverUrl: "https://api.example.com", - resource: "https://api.example.com/mcp-server-2/v1", + resource: new URL("https://api.example.com/mcp-server-2/v1"), }); expect(result2).toBe("REDIRECT"); @@ -1309,7 +1309,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing query parameters const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server?param=value&another=test", + resource: new URL("https://api.example.com/mcp-server?param=value&another=test"), }); expect(result).toBe("REDIRECT"); diff --git a/src/client/sse.ts b/src/client/sse.ts index c484bde96..41f21de65 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { extractResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -90,7 +90,7 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +210,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +249,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 25c41bf3f..3534fb459 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { extractResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -153,7 +153,7 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +371,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 68794c36b..63b47f53e 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -303,7 +303,7 @@ describe('Token Handler', () => { 'valid_code', undefined, // code_verifier is undefined after PKCE validation undefined, // redirect_uri - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -371,7 +371,7 @@ describe('Token Handler', () => { 'valid_code', undefined, // code_verifier is undefined after PKCE validation 'https://example.com/callback', // redirect_uri - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -585,7 +585,7 @@ describe('Token Handler', () => { validClient, 'valid_refresh_token', undefined, // scopes - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -648,7 +648,7 @@ describe('Token Handler', () => { validClient, 'valid_refresh_token', ['profile', 'email'], // scopes - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); }); diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 92fe99218..3ffd4cf28 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -113,7 +113,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand code, skipLocalPkceValidation ? code_verifier : undefined, redirect_uri, - resource + resource ? new URL(resource) : undefined ); res.status(200).json(tokens); break; @@ -131,7 +131,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand // The provider can decide how to validate it const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); break; } diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 75dc1a15a..b834c6592 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -213,7 +213,7 @@ describe("Proxy OAuth Server Provider", () => { 'test-code', 'test-verifier', 'https://example.com/callback', - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); expect(global.fetch).toHaveBeenCalledWith( @@ -267,7 +267,7 @@ describe("Proxy OAuth Server Provider", () => { validClient, 'test-refresh-token', ['read', 'write'], - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); expect(global.fetch).toHaveBeenCalledWith( @@ -301,7 +301,7 @@ describe("Proxy OAuth Server Provider", () => { validClient, 'test-refresh-token', ['profile', 'email'], - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 4c8074448..de74862b5 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -175,7 +175,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } if (resource) { - params.append("resource", resource); + params.append("resource", resource.href); } const response = await fetch(this._endpoints.tokenUrl, { @@ -217,7 +217,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(this._endpoints.tokenUrl, { From 6e4fc52c7e7433e7c4bff5b571620e51669f293a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:42:44 +0100 Subject: [PATCH 034/208] Update demoInMemoryOAuthProvider.test.ts --- .../server/demoInMemoryOAuthProvider.test.ts | 345 ++++-------------- 1 file changed, 78 insertions(+), 267 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 852f0c98f..9bdcfdfa6 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore, DemoOAuthProviderConfig } from './demoInMemoryOAuthProvider.js'; -import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; import { OAuthClientInformationFull } from '../../shared/auth.js'; import { Response } from 'express'; -describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { +describe('DemoInMemoryOAuthProvider', () => { let provider: DemoInMemoryAuthProvider; let clientsStore: DemoInMemoryClientsStore; - let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] }; + let mockClient: OAuthClientInformationFull; let mockResponse: Partial; beforeEach(() => { @@ -30,132 +29,104 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { }; }); - describe('Authorization with resource parameter', () => { - it('should allow authorization when no resources are configured', async () => { + describe('Basic authorization flow', () => { + it('should handle authorization successfully', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', + resource: new URL('https://api.example.com/v1'), scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); + }, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + expect(redirectCall).toContain('code='); }); - it('should allow authorization when resource is in allowed list', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2']; + it('should handle authorization without resource', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); + }, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); }); - it('should reject authorization when resource is not in allowed list', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1']; + it('should preserve state parameter', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.forbidden.com', + state: 'test-state', scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + expect(redirectCall).toContain('state=test-state'); }); }); - describe('Token exchange with resource validation', () => { + describe('Token exchange', () => { let authorizationCode: string; beforeEach(async () => { await clientsStore.registerClient(mockClient); - // Authorize without resource first await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], scopes: ['mcp:tools'] }, mockResponse as Response); - // Extract authorization code from redirect call const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; const url = new URL(redirectCall); authorizationCode = url.searchParams.get('code')!; }); - it('should exchange code successfully when resource matches', async () => { - // First authorize with a specific resource - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - + it('should exchange authorization code for tokens', async () => { const tokens = await provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.example.com/v1' + authorizationCode ); expect(tokens).toHaveProperty('access_token'); expect(tokens.token_type).toBe('bearer'); + expect(tokens.expires_in).toBe(3600); }); - it('should reject token exchange when resource does not match', async () => { - // First authorize with a specific resource - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - + it('should reject invalid authorization code', async () => { await expect(provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.different.com' - )).rejects.toThrow(InvalidTargetError); + 'invalid-code' + )).rejects.toThrow('Invalid authorization code'); }); - it('should reject token exchange when resource was not authorized but is requested', async () => { + it('should reject code from different client', async () => { + const otherClient: OAuthClientInformationFull = { + ...mockClient, + client_id: 'other-client' + }; + + await clientsStore.registerClient(otherClient); + await expect(provider.exchangeAuthorizationCode( - mockClient, - authorizationCode, - undefined, - undefined, - 'https://api.example.com/v1' - )).rejects.toThrow(InvalidTargetError); + otherClient, + authorizationCode + )).rejects.toThrow('Authorization code was not issued to this client'); }); - it('should store resource in token data', async () => { - // Authorize with resource + it('should store resource in token when provided during authorization', async () => { mockResponse.redirect = jest.fn(); await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', + resource: new URL('https://api.example.com/v1'), scopes: ['mcp:tools'] }, mockResponse as Response); @@ -165,225 +136,65 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { const tokens = await provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.example.com/v1' + codeWithResource ); - // Verify token has resource information const tokenDetails = provider.getTokenDetails(tokens.access_token); - expect(tokenDetails?.resource).toBe('https://api.example.com/v1'); - }); - }); - - describe('Refresh token with resource validation', () => { - it('should validate resource when exchanging refresh token', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1']; - await clientsStore.registerClient(mockClient); - - await expect(provider.exchangeRefreshToken( - mockClient, - 'refresh-token', - undefined, - 'https://api.forbidden.com' - )).rejects.toThrow(InvalidTargetError); + expect(tokenDetails?.resource).toEqual(new URL('https://api.example.com/v1')); }); }); - describe('Allowed resources management', () => { - it('should update allowed resources for a client', async () => { + describe('Token verification', () => { + it('should verify valid access token', async () => { await clientsStore.registerClient(mockClient); - // Initially no resources configured - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://any.api.com', scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - - // Set allowed resources - clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']); + }, mockResponse as Response); - // Now should reject unauthorized resources - await expect(provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://any.api.com', - scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); - }); - }); + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const code = url.searchParams.get('code')!; - describe('Server URL validation configuration', () => { - it('should throw error when validateResourceMatchesServer is true but serverUrl is not set', () => { - const invalidConfig: DemoOAuthProviderConfig = { - validateResourceMatchesServer: true - // serverUrl is missing - }; + const tokens = await provider.exchangeAuthorizationCode(mockClient, code); + const tokenInfo = await provider.verifyAccessToken(tokens.access_token); - expect(() => { - new DemoInMemoryAuthProvider(invalidConfig); - }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + expect(tokenInfo.clientId).toBe(mockClient.client_id); + expect(tokenInfo.scopes).toEqual(['mcp:tools']); }); - describe('with server URL validation enabled', () => { - let strictProvider: DemoInMemoryAuthProvider; - - beforeEach(() => { - const config: DemoOAuthProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true - }; - strictProvider = new DemoInMemoryAuthProvider(config); - - strictProvider.clientsStore.registerClient(mockClient); - }); - - it('should reject authorization without resource parameter', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - // resource is missing - }, mockResponse as Response)).rejects.toThrow(InvalidRequestError); - }); - - it('should reject authorization with non-matching resource', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://different.api.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); - }); - - it('should accept authorization with matching resource', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - - expect(mockResponse.redirect).toHaveBeenCalled(); - }); - - it('should handle server URL with fragment correctly', async () => { - const configWithFragment: DemoOAuthProviderConfig = { - serverUrl: 'https://api.example.com/mcp#fragment', - validateResourceMatchesServer: true - }; - const providerWithFragment = new DemoInMemoryAuthProvider(configWithFragment); - - await providerWithFragment.clientsStore.registerClient(mockClient); - - // Should accept resource without fragment - await expect(providerWithFragment.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - }); - - it('should reject token exchange without resource parameter', async () => { - // First authorize with resource - mockResponse.redirect = jest.fn(); - await strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const authCode = url.searchParams.get('code')!; - - await expect(strictProvider.exchangeAuthorizationCode( - mockClient, - authCode, - undefined, - undefined - // resource is missing - )).rejects.toThrow(InvalidRequestError); - }); - - it('should reject refresh token without resource parameter', async () => { - await expect(strictProvider.exchangeRefreshToken( - mockClient, - 'refresh-token', - undefined - // resource is missing - )).rejects.toThrow(InvalidRequestError); - }); + it('should reject invalid token', async () => { + await expect(provider.verifyAccessToken('invalid-token')) + .rejects.toThrow('Invalid or expired token'); }); + }); - describe('with server URL validation disabled (warning mode)', () => { - let warnProvider: DemoInMemoryAuthProvider; - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - warnProvider = new DemoInMemoryAuthProvider(); // No config = warnings enabled - - warnProvider.clientsStore.registerClient(mockClient); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - - it('should log warning when resource is missing from authorization', async () => { - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - // resource is missing - }, mockResponse as Response); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('test-client is missing the resource parameter') - ); - }); - - it('should not log warning when resource is present', async () => { - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - it('should log warning when resource is missing from token exchange', async () => { - // First authorize without resource - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); + describe('Refresh token', () => { + it('should throw error for refresh token (not implemented)', async () => { + await clientsStore.registerClient(mockClient); - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const authCode = url.searchParams.get('code')!; + await expect(provider.exchangeRefreshToken( + mockClient, + 'refresh-token' + )).rejects.toThrow('Refresh tokens not implemented for example demo'); + }); + }); - await warnProvider.exchangeAuthorizationCode( - mockClient, - authCode, - undefined, - undefined - // resource is missing - ); + describe('Server URL validation', () => { + it('should accept mcpServerUrl configuration', () => { + const serverUrl = new URL('https://api.example.com/mcp'); + const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); + + expect(providerWithUrl).toBeDefined(); + }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('test-client is missing the resource parameter') - ); - }); + it('should handle server URL with fragment', () => { + const serverUrl = new URL('https://api.example.com/mcp#fragment'); + const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); + + expect(providerWithUrl).toBeDefined(); }); }); }); \ No newline at end of file From ec0c50425ef4119f9547fcafc16f2eda95664956 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 10:59:36 +0100 Subject: [PATCH 035/208] rm noise --- CLAUDE.local.md | 125 ---------------------------------------------- PR-DESCRIPTION.md | 111 ---------------------------------------- 2 files changed, 236 deletions(-) delete mode 100644 CLAUDE.local.md delete mode 100644 PR-DESCRIPTION.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md deleted file mode 100644 index 9a43ac7ca..000000000 --- a/CLAUDE.local.md +++ /dev/null @@ -1,125 +0,0 @@ -# RFC 8707 Resource Indicators Implementation for MCP TypeScript SDK - -This PR implements RFC 8707 (Resource Indicators for OAuth 2.0) in the MCP TypeScript SDK, addressing critical security vulnerabilities and adding resource-scoped authorization support. - -## Issues Addressed - -- **Fixes #592**: Implements client-side resource parameter passing to prevent token confusion attacks -- **Related to #635**: Demonstrates server-side RFC 8707 validation in the demo OAuth provider - -## Overview - -This implementation adds resource parameter support to MCP's OAuth flow, explicitly binding access tokens to specific MCP servers. This prevents malicious servers from stealing OAuth tokens intended for other services. - -## Implementation Summary - -### 1. Core Auth Infrastructure - -#### Client-Side Changes (`src/client/`) -- **auth.ts**: Added resource parameter support to authorization and token exchange flows -- **Transport layers** (sse.ts, streamableHttp.ts): Automatically extract canonical server URIs for resource parameter - -#### Server-Side Changes (`src/server/auth/`) -- **handlers/**: Updated authorize and token handlers to accept and pass through resource parameters -- **provider.ts**: Extended provider interface to support resource parameters -- **errors.ts**: Added `InvalidTargetError` for RFC 8707 compliance - -#### Shared Utilities (`src/shared/`) -- **auth-utils.ts**: Created utilities for resource URI validation and canonicalization -- **auth.ts**: Updated OAuth schemas to include resource parameter - -### 2. Demo OAuth Provider Enhancement (`src/examples/server/`) - -The demo provider demonstrates how to implement RFC 8707 validation: -- Optional resource validation during authorization (via `DemoOAuthProviderConfig`) -- Resource consistency checks during token exchange -- Resource information included in token introspection -- Support for validating resources against a configured server URL -- Client-specific resource allowlists - -### 3. Resource URI Requirements - -Resource URIs follow RFC 8707 requirements: -- **MUST NOT** include fragments (automatically removed by the SDK) -- The SDK preserves all other URL components (scheme, host, port, path, query) exactly as provided -- No additional canonicalization is performed to maintain compatibility with various server configurations - -## Client vs Server Implementation Differences - -### Client-Side Implementation -- **Automatic resource extraction**: Transports automatically determine the server URI for resource parameter -- **Transparent integration**: Resource parameter is added without changing existing auth APIs -- **Fragment removal**: Fragments are automatically removed from URIs per RFC 8707 -- **Focus**: Ensuring resource parameter is correctly included in all OAuth requests - -### Server-Side Implementation -- **Core handlers**: Pass through resource parameter without validation -- **Demo provider**: Shows how to implement resource validation -- **Provider flexibility**: Auth providers decide how to enforce resource restrictions -- **Backward compatibility**: Servers work with clients that don't send resource parameter -- **Focus**: Demonstrating best practices for resource validation - -## Testing Approach Differences - -### Client-Side Tests -- **Unit tests**: Verify resource parameter is included in auth URLs and token requests -- **Validation tests**: Ensure resource URI validation and canonicalization work correctly -- **Integration focus**: Test interaction between transport layer and auth module - -### Server-Side Tests -- **Handler tests**: Verify resource parameter is accepted and passed to providers -- **Demo provider tests**: Comprehensive tests for server URL validation and client-specific allowlists -- **Security tests**: Verify invalid resources are rejected with proper errors -- **Configuration tests**: Test various demo provider configurations -- **End-to-end tests**: Full OAuth flow with resource validation - -## Security Considerations - -1. **Token Binding**: Tokens are explicitly bound to the resource they're intended for -2. **Validation**: Both client and server validate resource URIs to prevent attacks -3. **Consistency**: Resource must match between authorization and token exchange -4. **Introspection**: Resource information is included in token introspection responses - -## Migration Guide - -### For Client Developers -No changes required - the SDK automatically includes the resource parameter based on the server URL. - -### For Server Developers -1. Core server handlers automatically pass through the resource parameter -2. Custom auth providers can implement resource validation as shown in the demo provider -3. Demo provider configuration options: - - `serverUrl`: The canonical URL of the MCP server - - `validateResourceMatchesServer`: Enable strict resource validation -4. Return `invalid_target` error for unauthorized resources -5. Include resource in token introspection responses - -## Example Usage - -```typescript -// Client automatically includes resource parameter -const transport = new StreamableHttpClientTransport( - 'https://api.example.com/mcp', - authProvider -); - -// Demo provider configuration with resource validation -const demoProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true // Makes resource required and validates it -}; -const provider = new DemoInMemoryAuthProvider(demoProviderConfig); -``` - -## Future Enhancements - -1. Add support for multiple resource parameters (RFC 8707 allows arrays) -2. Implement resource-specific scope restrictions -3. Add telemetry for resource parameter usage -4. Create migration tooling for existing deployments - -## References - -- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) -- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) -- [MCP Issue #544 - Security Vulnerability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544) \ No newline at end of file diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md deleted file mode 100644 index b4e48cbd2..000000000 --- a/PR-DESCRIPTION.md +++ /dev/null @@ -1,111 +0,0 @@ -# RFC 8707 Resource Indicators Implementation - - -Implements RFC 8707 (Resource Indicators for OAuth 2.0) support in the MCP TypeScript SDK. This adds the `resource` parameter to OAuth authorization and token exchange flows, allowing access tokens to be explicitly bound to specific MCP servers. The implementation includes automatic resource extraction in client transports, server-side parameter passing, and demonstrates resource validation in the demo OAuth provider. - -(Fixes #592, Related to #635) - -## Motivation and Context - -This change addresses critical security vulnerabilities identified in https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544. Without resource indicators, OAuth tokens intended for one MCP server could be stolen and misused by malicious servers. RFC 8707 prevents these token confusion attacks by explicitly binding tokens to their intended resources. - -Key problems solved: -- Prevents token theft/confusion attacks where a malicious MCP server steals tokens meant for other services -- Enables fine-grained access control by restricting OAuth clients to specific resources -- Improves security posture by following OAuth 2.0 Security Best Current Practice recommendations - -## How Has This Been Tested? - -Comprehensive test coverage has been added: - -**Client-side testing:** -- Unit tests verify resource parameter inclusion in authorization URLs and token requests (512 new lines in auth.test.ts) -- Transport layer tests ensure automatic resource extraction works correctly -- Fragment removal and URI validation tests - -**Server-side testing:** -- Authorization handler tests for resource parameter acceptance -- Token handler tests for resource parameter passing -- Demo provider tests for resource restrictions and validation (including server URL validation) -- Proxy provider tests for resource parameter forwarding - -**Integration testing:** -- End-to-end OAuth flow with resource validation -- Resource validation example demonstrating real-world usage patterns -- Tests for both clients with and without resource restrictions - -## Breaking Changes - -While the change is breaking at a protocol level, it should not require code changes from SDK users (just SDK version bumping). - -- **Client developers**: No code changes required. The SDK automatically extracts and includes the resource parameter from the server URL -- **Server developers**: The core server handlers now pass through the resource parameter. Resource validation is demonstrated in the demo provider but remains optional for custom providers -- **Auth providers**: Should be updated to accept and handle the resource parameter. The demo provider shows how to implement server URL validation and client-specific resource restrictions - -## Types of changes - -- [x] Bug fix (non-breaking change which fixes an issue) -- [x] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [ ] Documentation update - -## Checklist - -- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) -- [x] My code follows the repository's style guidelines -- [x] New and existing tests pass locally -- [x] I have added appropriate error handling -- [x] I have added or updated documentation as needed - -## Additional context - - -### Server-Side Implementation Approach - -The core server implementation focuses on passing through the resource parameter without enforcing validation, maintaining backward compatibility and flexibility. The demo provider demonstrates how to implement RFC 8707 validation: - -1. **Core Server**: Handlers accept and forward the resource parameter to auth providers without validation -2. **Demo Provider**: Shows how to implement comprehensive resource validation including: - - Server URL matching validation (configurable via `DemoOAuthProviderConfig`) - - Client-specific resource allowlists - - Warning logs for missing resource parameters - - Consistent resource validation between authorization and token exchange - -This separation allows: -- Existing providers to continue working without modification -- New providers to implement validation according to their security requirements -- Gradual migration to RFC 8707 compliance -- Different validation strategies for different deployment scenarios - -### Implementation Approach - -Resource URIs are used as-is with only fragment removal (per RFC requirement). This allows having different MCP servers under different subpaths (even w/ different query URLs) w/o sharing spilling their resource authorization to each other (to allow a variety of MCP server federation use cases). - -### Key Components Added -1. **Shared utilities** (`auth-utils.ts`): Resource URI handling and validation -2. **Client auth** modifications: Resource parameter support in authorization/token flows -3. **Transport layers**: Automatic resource extraction from server URLs -4. **Server handlers**: Resource parameter acceptance and forwarding -5. **Demo provider**: Full RFC 8707 implementation with resource validation -6. **Error handling**: New `InvalidTargetError` for RFC 8707 compliance - -### Example Usage -```typescript -// Client-side (automatic) -const transport = new StreamableHttpClientTransport( - 'https://api.example.com/mcp', - authProvider -); - -// Demo provider configuration with validation -const demoProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true // Makes resource required and validates it matches serverUrl -}; -const provider = new DemoInMemoryAuthProvider(demoProviderConfig); -``` - -### References -- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) -- Fixes #592: OAuth token confusion vulnerability - client-side resource parameter support -- Related to #635: Demonstrates server-side RFC 8707 validation in demo provider \ No newline at end of file From b16a415623442f4f9e3f3385555afcdc30cf4fa3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:15:38 +0100 Subject: [PATCH 036/208] cleanups --- src/client/auth.test.ts | 27 +++++++------------ src/client/auth.ts | 16 ++++------- src/client/sse.ts | 3 --- src/client/streamableHttp.ts | 3 --- .../server/demoInMemoryOAuthProvider.ts | 12 +++++++-- src/examples/server/simpleStreamableHttp.ts | 7 +---- src/server/auth/handlers/authorize.ts | 3 --- src/server/auth/handlers/token.ts | 6 ----- 8 files changed, 25 insertions(+), 52 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 44516130a..2cd9a2d19 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -938,8 +938,7 @@ describe("OAuth Authorization", () => { // Call the auth function with a resource that has a fragment const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server#fragment"), + serverUrl: "https://api.example.com/mcp-server#fragment", }); expect(result).toBe("REDIRECT"); @@ -987,8 +986,7 @@ describe("OAuth Authorization", () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server"), + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); @@ -1048,9 +1046,8 @@ describe("OAuth Authorization", () => { // Call auth with authorization code const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", + serverUrl: "https://api.example.com/mcp-server", authorizationCode: "auth-code-123", - resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1111,8 +1108,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server"), + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("AUTHORIZED"); @@ -1160,8 +1156,7 @@ describe("OAuth Authorization", () => { // Call auth with empty resource parameter const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: undefined, + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); @@ -1203,8 +1198,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing multiple # symbols const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server#fragment#another"), + serverUrl: "https://api.example.com/mcp-server#fragment#another", }); expect(result).toBe("REDIRECT"); @@ -1248,8 +1242,7 @@ describe("OAuth Authorization", () => { // This tests the security fix that prevents token confusion between // multiple MCP servers on the same domain const result1 = await auth(mockProvider, { - serverUrl: "https://api.example.com", - resource: new URL("https://api.example.com/mcp-server-1/v1"), + serverUrl: "https://api.example.com/mcp-server-1/v1", }); expect(result1).toBe("REDIRECT"); @@ -1263,8 +1256,7 @@ describe("OAuth Authorization", () => { // Test with different path on same domain const result2 = await auth(mockProvider, { - serverUrl: "https://api.example.com", - resource: new URL("https://api.example.com/mcp-server-2/v1"), + serverUrl: "https://api.example.com/mcp-server-2/v1", }); expect(result2).toBe("REDIRECT"); @@ -1308,8 +1300,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing query parameters const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server?param=value&another=test"), + serverUrl: "https://api.example.com/mcp-server?param=value&another=test", }); expect(result).toBe("REDIRECT"); diff --git a/src/client/auth.ts b/src/client/auth.ts index e465ea3be..681cde997 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -94,19 +94,13 @@ export async function auth( authorizationCode, scope, resourceMetadataUrl, - resource }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL; - resource?: URL }): Promise { + resourceMetadataUrl?: URL }): Promise { - // Remove fragment from resource parameter if provided - let canonicalResource: URL | undefined; - if (resource) { - canonicalResource = resourceUrlFromServerUrl(new URL(resource)); - } + const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); let authorizationServerUrl = serverUrl; try { @@ -151,7 +145,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, - resource: canonicalResource, + resource, }); await provider.saveTokens(tokens); @@ -168,7 +162,7 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, - resource: canonicalResource, + resource, }); await provider.saveTokens(newTokens); @@ -187,7 +181,7 @@ export async function auth( state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, - resource: canonicalResource, + resource, }); await provider.saveCodeVerifier(codeVerifier); diff --git a/src/client/sse.ts b/src/client/sse.ts index 41f21de65..7a500c6be 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -90,7 +90,6 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +209,6 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +247,6 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 3534fb459..85a0ad105 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -153,7 +153,6 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +370,6 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +421,6 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 316d1f8a4..d6a643986 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -21,6 +21,14 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { } } +/** + * 🚨 DEMO ONLY - NOT FOR PRODUCTION + * + * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, + * for example: + * - Persistent token storage + * - Rate limiting + */ export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map { - throw new Error('Refresh tokens not implemented for example demo'); + throw new Error('Not implemented for example demo'); } async verifyAccessToken(token: string): Promise { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 65b6263ec..da5e740a6 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -282,12 +282,7 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - // Configure the demo auth provider to validate resources match this server - const demoProviderConfig = { - serverUrl: mcpServerUrl.href, - validateResourceMatchesServer: false // Set to true to enable strict validation - }; - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, demoProviderConfig); + const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { verifyAccessToken: async (token: string) => { diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 17c88b45a..0a6283a8b 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -119,9 +119,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - // Validate scopes let requestedScopes: string[] = []; if (scope !== undefined) { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 3ffd4cf28..1d97805bc 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -93,9 +93,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const { code, code_verifier, redirect_uri, resource } = parseResult.data; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - const skipLocalPkceValidation = provider.skipLocalPkceValidation; // Perform local PKCE validation unless explicitly skipped @@ -127,9 +124,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const { refresh_token, scope, resource } = parseResult.data; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - const scopes = scope?.split(" "); const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); From badb5dc990d9d782dbe41cd7f010c140e8308f53 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:23:41 +0100 Subject: [PATCH 037/208] fix tests --- src/client/auth.test.ts | 9 +++++---- src/examples/server/demoInMemoryOAuthProvider.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 2cd9a2d19..9ee4e6cf2 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1125,7 +1125,7 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); - it("handles empty resource parameter", async () => { + it("handles derived resource parameter from serverUrl", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { const urlString = url.toString(); @@ -1154,17 +1154,18 @@ describe("OAuth Authorization", () => { (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - // Call auth with empty resource parameter + // Call auth with just serverUrl (resource is derived from it) const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); - // Verify that empty resource is not included in the URL + // Verify that resource parameter is always included (derived from serverUrl) const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.has("resource")).toBe(false); + expect(authUrl.searchParams.has("resource")).toBe(true); + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); }); it("handles resource with multiple fragments", async () => { diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 9bdcfdfa6..e3a478131 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -178,7 +178,7 @@ describe('DemoInMemoryOAuthProvider', () => { await expect(provider.exchangeRefreshToken( mockClient, 'refresh-token' - )).rejects.toThrow('Refresh tokens not implemented for example demo'); + )).rejects.toThrow('Not implemented for example demo'); }); }); From 515abb492fc722bc5925d777685147d5fc746580 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:53:26 +0100 Subject: [PATCH 038/208] fix lints --- src/client/sse.ts | 1 - src/client/streamableHttp.ts | 1 - src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 7a500c6be..0a238d98d 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,6 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 85a0ad105..c810588f9 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,6 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index d6a643986..3133e4552 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -130,7 +130,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _client: OAuthClientInformationFull, _refreshToken: string, _scopes?: string[], - resource?: URL + _resource?: URL ): Promise { throw new Error('Not implemented for example demo'); } From 40f61d88a0e02f0e710cf19c053c562bf47de54f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 17:05:34 +0100 Subject: [PATCH 039/208] show how to enable strict resource checking in mcp server --- .../server/demoInMemoryOAuthProvider.ts | 3 +- src/examples/server/simpleStreamableHttp.ts | 18 ++++++++++- src/server/auth/middleware/bearerAuth.test.ts | 31 +++++++++++++------ src/server/auth/middleware/bearerAuth.ts | 8 ++++- src/server/auth/provider.ts | 2 +- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 3133e4552..500f59b8a 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -41,7 +41,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (mcpServerUrl) { const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); this.validateResource = (resource?: URL) => { - return !resource || resource.toString() !== expectedResource.toString(); + if (!resource) return false; + return resource.toString() === expectedResource.toString(); }; } } diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index da5e740a6..a97fdf5a5 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,6 +12,13 @@ import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); +// Resource Indicator the OAuth tokens are checked against (RFC8707). +const expectedOAuthResource = (iArg => iArg < 0 ? undefined: process.argv[iArg + 1])(process.argv.indexOf('--oauth-resource')); +// Requires Resource Indicator check (implies protocol more recent than 2025-03-26) +const strictOAuthResourceCheck = process.argv.includes('--oauth-resource-strict'); +if (strictOAuthResourceCheck && !expectedOAuthResource) { + throw new Error(`Strict resource indicator checking requires passing the expected resource with --oauth-resource https://...`); +} // Create an MCP server with implementation details const getServer = () => { @@ -285,7 +292,7 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { - verifyAccessToken: async (token: string) => { + verifyAccessToken: async (token: string, protocolVersion: string) => { const endpoint = oauthMetadata.introspection_endpoint; if (!endpoint) { @@ -308,6 +315,15 @@ if (useOAuth) { } const data = await response.json(); + + if (expectedOAuthResource) { + if (strictOAuthResourceCheck && !data.resource) { + throw new Error('Resource Indicator (RFC8707) missing'); + } + if (data.resource && data.resource !== expectedOAuthResource) { + throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); + } + } // Convert the response to AuthInfo format return { diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index b8953e5c9..cae054d50 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,6 +3,7 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; +import { LATEST_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -42,12 +43,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(validAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -65,12 +67,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer expired-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -93,12 +96,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -115,6 +119,7 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -124,7 +129,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -146,6 +151,7 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -155,7 +161,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(authInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -204,6 +210,7 @@ describe("requireBearerAuth middleware", () => { it("should return 401 when token verification fails with InvalidTokenError", async () => { mockRequest.headers = { authorization: "Bearer invalid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); @@ -211,7 +218,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -226,6 +233,7 @@ describe("requireBearerAuth middleware", () => { it("should return 403 when access token has insufficient scopes", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); @@ -233,7 +241,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -248,6 +256,7 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when a ServerError occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); @@ -255,7 +264,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal server issue" }) @@ -266,6 +275,7 @@ describe("requireBearerAuth middleware", () => { it("should return 400 for generic OAuthError", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); @@ -273,7 +283,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" }) @@ -284,6 +294,7 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when unexpected error occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); @@ -291,7 +302,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" }) diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index fd96055ab..4674089f9 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -2,6 +2,7 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; +import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../../../types.js"; export type BearerAuthMiddlewareOptions = { /** @@ -50,7 +51,12 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - const authInfo = await verifier.verifyAccessToken(token); + let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + const authInfo = await verifier.verifyAccessToken(token, protocolVersion); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 18beb2166..409e9dae4 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -80,5 +80,5 @@ export interface OAuthTokenVerifier { /** * Verifies an access token and returns information about it. */ - verifyAccessToken(token: string): Promise; + verifyAccessToken(token: string, protocolVersion: string): Promise; } From 617faccf8ec9f6e06a3164d689225f9271022ef5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 17:08:21 +0100 Subject: [PATCH 040/208] Add test for default protocol version negotiation in bearerAuth middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tests that when mcp-protocol-version header is missing, the middleware uses DEFAULT_NEGOTIATED_PROTOCOL_VERSION when calling verifyAccessToken - Ensures proper fallback behavior for protocol version negotiation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/auth/middleware/bearerAuth.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index cae054d50..665ef9261 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,7 +3,7 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; -import { LATEST_PROTOCOL_VERSION } from '../../../types.js'; +import { LATEST_PROTOCOL_VERSION, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -56,6 +56,28 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.json).not.toHaveBeenCalled(); }); + it("should use default negotiated protocol version when mcp-protocol-version header is missing", async () => { + const validAuthInfo: AuthInfo = { + token: "valid-token", + clientId: "client-123", + scopes: ["read", "write"], + }; + mockVerifyAccessToken.mockResolvedValue(validAuthInfo); + + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + expect(mockRequest.auth).toEqual(validAuthInfo); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", From c5922f8765c17e3ba070fd43e57b12071306623c Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 17 Jun 2025 10:07:59 -0700 Subject: [PATCH 041/208] 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 d36bb8f27..adbc9025a 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 c2150f0cb0a5cc99ee7cdc314e51f828f5ee34f5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 18:41:37 +0100 Subject: [PATCH 042/208] Update README.md --- src/examples/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/examples/README.md b/src/examples/README.md index 68e1ece23..c074c7577 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -76,6 +76,9 @@ npx tsx src/examples/server/simpleStreamableHttp.ts # To add a demo of authentication to this example, use: npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-resource=https://some-mcp-server.com --oauth-resource-strict ``` ##### JSON Response Mode Server From bf72f87788dd8179611a4dfa2af19dd639e4698a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:23:09 +0100 Subject: [PATCH 043/208] cleanups --- src/examples/README.md | 2 +- src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- src/examples/server/simpleStreamableHttp.ts | 16 +++++----------- src/server/auth/middleware/bearerAuth.ts | 7 +------ src/server/auth/provider.ts | 2 +- 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index c074c7577..ac92e8ded 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -78,7 +78,7 @@ npx tsx src/examples/server/simpleStreamableHttp.ts npx tsx src/examples/server/simpleStreamableHttp.ts --oauth # To mitigate impersonation risks, enable strict Resource Identifier verification: -npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-resource=https://some-mcp-server.com --oauth-resource-strict +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict ``` ##### JSON Response Mode Server diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 500f59b8a..5c34166e2 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -102,7 +102,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } if (this.validateResource && !this.validateResource(codeData.params.resource)) { - throw new Error('Invalid resource'); + throw new Error(`Invalid resource: ${codeData.params.resource}`); } this.codes.delete(authorizationCode); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a97fdf5a5..fdac53572 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,13 +12,7 @@ import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); -// Resource Indicator the OAuth tokens are checked against (RFC8707). -const expectedOAuthResource = (iArg => iArg < 0 ? undefined: process.argv[iArg + 1])(process.argv.indexOf('--oauth-resource')); -// Requires Resource Indicator check (implies protocol more recent than 2025-03-26) -const strictOAuthResourceCheck = process.argv.includes('--oauth-resource-strict'); -if (strictOAuthResourceCheck && !expectedOAuthResource) { - throw new Error(`Strict resource indicator checking requires passing the expected resource with --oauth-resource https://...`); -} +const strictOAuth = process.argv.includes('--oauth-strict'); // Create an MCP server with implementation details const getServer = () => { @@ -292,7 +286,7 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { - verifyAccessToken: async (token: string, protocolVersion: string) => { + verifyAccessToken: async (token: string) => { const endpoint = oauthMetadata.introspection_endpoint; if (!endpoint) { @@ -316,11 +310,11 @@ if (useOAuth) { const data = await response.json(); - if (expectedOAuthResource) { - if (strictOAuthResourceCheck && !data.resource) { + if (strictOAuth) { + if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); } - if (data.resource && data.resource !== expectedOAuthResource) { + if (data.resource !== expectedOAuthResource) { throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); } } diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 4674089f9..a34625d1e 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -51,12 +51,7 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; - if (Array.isArray(protocolVersion)) { - protocolVersion = protocolVersion[protocolVersion.length - 1]; - } - - const authInfo = await verifier.verifyAccessToken(token, protocolVersion); + const authInfo = await verifier.verifyAccessToken(token); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 409e9dae4..18beb2166 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -80,5 +80,5 @@ export interface OAuthTokenVerifier { /** * Verifies an access token and returns information about it. */ - verifyAccessToken(token: string, protocolVersion: string): Promise; + verifyAccessToken(token: string): Promise; } From 4a88cac4c63bcf99a4f96452f0e70fb455df78ef Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:23:57 +0100 Subject: [PATCH 044/208] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index fdac53572..3c7318fd0 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -314,8 +314,8 @@ if (useOAuth) { if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); } - if (data.resource !== expectedOAuthResource) { - throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); + if (data.resource !== mcpServerUrl) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.resource}`); } } From d58c2eb114bb56596b0dc72cd20df2ddb092e088 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:26:28 +0100 Subject: [PATCH 045/208] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3c7318fd0..9ca48fdb0 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -280,7 +280,7 @@ app.use(express.json()); let authMiddleware = null; if (useOAuth) { // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); From aebb2ab197a04c7dc9933b4f6e36e37233ef7b42 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:39:29 +0100 Subject: [PATCH 046/208] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 9ca48fdb0..068a01441 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -311,11 +311,11 @@ if (useOAuth) { const data = await response.json(); if (strictOAuth) { - if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); + if (!data.aud) { } - if (data.resource !== mcpServerUrl) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.resource}`); + if (data.aud !== mcpServerUrl.href) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); } } From 049170db7ca50a4148ba1e37f410926d0b90f212 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:41:09 +0100 Subject: [PATCH 047/208] minimize diff --- src/client/sse.ts | 16 +++------------- src/client/streamableHttp.ts | 11 ++--------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 0a238d98d..5aa99abb4 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -86,10 +86,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -204,11 +201,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { - serverUrl: this._url, - authorizationCode, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -243,10 +236,7 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index c810588f9..f64c1ad88 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -365,11 +365,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { - serverUrl: this._url, - authorizationCode, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -417,10 +413,7 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } From b77361bd14e65e09aaa8e600e4bce7634f591df1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:41:35 +0100 Subject: [PATCH 048/208] Update streamableHttp.ts --- src/client/streamableHttp.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index f64c1ad88..4117bb1b4 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -149,10 +149,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; From 8475e43f15e25e316d51f004a9465b071a3d538c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:48:20 +0100 Subject: [PATCH 049/208] drop redundant resource canonicalization tests --- src/client/auth.test.ts | 133 ---------------------------------------- 1 file changed, 133 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9ee4e6cf2..c6d533432 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -907,54 +907,6 @@ describe("OAuth Authorization", () => { ); }); - it("canonicalizes resource URI by removing fragment", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - 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 the auth function with a resource that has a fragment - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server#fragment", - }); - - expect(result).toBe("REDIRECT"); - - // Verify redirectToAuthorization was called with the canonicalized resource - 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]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - it("passes resource parameter through authorization flow", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { @@ -1125,91 +1077,6 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); - it("handles derived resource parameter from serverUrl", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - 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 just serverUrl (resource is derived from it) - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server", - }); - - expect(result).toBe("REDIRECT"); - - // Verify that resource parameter is always included (derived from serverUrl) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.has("resource")).toBe(true); - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - - it("handles resource with multiple fragments", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - 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 resource containing multiple # symbols - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server#fragment#another", - }); - - expect(result).toBe("REDIRECT"); - - // Verify the resource is properly canonicalized (everything after first # removed) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - it("verifies resource parameter distinguishes between different paths on same domain", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { From e5b2a5b880d21f768c868c12e0612255fd6a72ad Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:50:29 +0100 Subject: [PATCH 050/208] fix simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 068a01441..9eb87d92f 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -311,8 +311,8 @@ if (useOAuth) { const data = await response.json(); if (strictOAuth) { - throw new Error('Resource Indicator (RFC8707) missing'); if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); } if (data.aud !== mcpServerUrl.href) { throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); From 4fcbb6870bbdd3c582346c7e66887b2660575827 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:50:39 +0100 Subject: [PATCH 051/208] verify PRM resource --- src/client/auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 681cde997..5fa2dee24 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -110,6 +110,9 @@ export async function auth( if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } + if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); + } } catch (error) { console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } From 68424ef7e6408a3e3b458e466a0dacdd5b8d0d99 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:59:19 +0100 Subject: [PATCH 052/208] simplify changes --- src/client/auth.test.ts | 88 -------- .../server/demoInMemoryOAuthProvider.test.ts | 200 ------------------ .../server/demoInMemoryOAuthProvider.ts | 11 +- 3 files changed, 1 insertion(+), 298 deletions(-) delete mode 100644 src/examples/server/demoInMemoryOAuthProvider.test.ts diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index c6d533432..ec913ecd9 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -354,18 +354,6 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter when not provided", async () => { - const { authorizationUrl } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - } - ); - - expect(authorizationUrl.searchParams.has("resource")).toBe(false); - }); - it("includes scope parameter when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -535,24 +523,6 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter from token exchange when not provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokens, - }); - - await exchangeAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - authorizationCode: "code123", - codeVerifier: "verifier123", - redirectUri: "http://localhost:3000/callback", - }); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; - expect(body.has("resource")).toBe(false); - }); - it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -659,22 +629,6 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter from refresh token request when not provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokensWithNewRefreshToken, - }); - - await refreshAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - refreshToken: "refresh123", - }); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; - expect(body.has("resource")).toBe(false); - }); - it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1136,47 +1090,5 @@ describe("OAuth Authorization", () => { // Verify that the two resources are different (critical for security) expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); }); - - it("preserves query parameters in resource URI", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - 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 resource containing query parameters - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server?param=value&another=test", - }); - - expect(result).toBe("REDIRECT"); - - // Verify query parameters are preserved (only fragment is removed) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server?param=value&another=test"); - }); }); }); diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts deleted file mode 100644 index e3a478131..000000000 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; -import { OAuthClientInformationFull } from '../../shared/auth.js'; -import { Response } from 'express'; - -describe('DemoInMemoryOAuthProvider', () => { - let provider: DemoInMemoryAuthProvider; - let clientsStore: DemoInMemoryClientsStore; - let mockClient: OAuthClientInformationFull; - let mockResponse: Partial; - - beforeEach(() => { - provider = new DemoInMemoryAuthProvider(); - clientsStore = provider.clientsStore as DemoInMemoryClientsStore; - - mockClient = { - client_id: 'test-client', - client_name: 'Test Client', - client_uri: 'https://example.com', - redirect_uris: ['https://example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools', - token_endpoint_auth_method: 'none', - }; - - mockResponse = { - redirect: jest.fn(), - }; - }); - - describe('Basic authorization flow', () => { - it('should handle authorization successfully', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: new URL('https://api.example.com/v1'), - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(mockResponse.redirect).toHaveBeenCalled(); - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - expect(redirectCall).toContain('code='); - }); - - it('should handle authorization without resource', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(mockResponse.redirect).toHaveBeenCalled(); - }); - - it('should preserve state parameter', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - state: 'test-state', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - expect(redirectCall).toContain('state=test-state'); - }); - }); - - describe('Token exchange', () => { - let authorizationCode: string; - - beforeEach(async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - authorizationCode = url.searchParams.get('code')!; - }); - - it('should exchange authorization code for tokens', async () => { - const tokens = await provider.exchangeAuthorizationCode( - mockClient, - authorizationCode - ); - - expect(tokens).toHaveProperty('access_token'); - expect(tokens.token_type).toBe('bearer'); - expect(tokens.expires_in).toBe(3600); - }); - - it('should reject invalid authorization code', async () => { - await expect(provider.exchangeAuthorizationCode( - mockClient, - 'invalid-code' - )).rejects.toThrow('Invalid authorization code'); - }); - - it('should reject code from different client', async () => { - const otherClient: OAuthClientInformationFull = { - ...mockClient, - client_id: 'other-client' - }; - - await clientsStore.registerClient(otherClient); - - await expect(provider.exchangeAuthorizationCode( - otherClient, - authorizationCode - )).rejects.toThrow('Authorization code was not issued to this client'); - }); - - it('should store resource in token when provided during authorization', async () => { - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: new URL('https://api.example.com/v1'), - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode( - mockClient, - codeWithResource - ); - - const tokenDetails = provider.getTokenDetails(tokens.access_token); - expect(tokenDetails?.resource).toEqual(new URL('https://api.example.com/v1')); - }); - }); - - describe('Token verification', () => { - it('should verify valid access token', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const code = url.searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode(mockClient, code); - const tokenInfo = await provider.verifyAccessToken(tokens.access_token); - - expect(tokenInfo.clientId).toBe(mockClient.client_id); - expect(tokenInfo.scopes).toEqual(['mcp:tools']); - }); - - it('should reject invalid token', async () => { - await expect(provider.verifyAccessToken('invalid-token')) - .rejects.toThrow('Invalid or expired token'); - }); - }); - - describe('Refresh token', () => { - it('should throw error for refresh token (not implemented)', async () => { - await clientsStore.registerClient(mockClient); - - await expect(provider.exchangeRefreshToken( - mockClient, - 'refresh-token' - )).rejects.toThrow('Not implemented for example demo'); - }); - }); - - describe('Server URL validation', () => { - it('should accept mcpServerUrl configuration', () => { - const serverUrl = new URL('https://api.example.com/mcp'); - const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); - - expect(providerWithUrl).toBeDefined(); - }); - - it('should handle server URL with fragment', () => { - const serverUrl = new URL('https://api.example.com/mcp#fragment'); - const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); - - expect(providerWithUrl).toBeDefined(); - }); - }); -}); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 5c34166e2..fe8d3f9cf 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -150,13 +150,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { resource: tokenData.resource, }; } - - /** - * Get token details including resource information (for demo introspection endpoint) - */ - getTokenDetails(token: string): AuthInfo | undefined { - return this.tokens.get(token); - } } @@ -190,14 +183,12 @@ export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMet } const tokenInfo = await provider.verifyAccessToken(token); - // For demo purposes, we'll add a method to get token details - const tokenDetails = provider.getTokenDetails(token); res.json({ active: true, client_id: tokenInfo.clientId, scope: tokenInfo.scopes.join(' '), exp: tokenInfo.expiresAt, - ...(tokenDetails?.resource && { aud: tokenDetails.resource }) + aud: tokenInfo.resource, }); return } catch (error) { From 9e2a565164b121671b0f34adca0f8edc7768fffb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:01:51 +0100 Subject: [PATCH 053/208] minimize changes --- src/server/auth/handlers/authorize.test.ts | 96 +------------------ src/server/auth/middleware/bearerAuth.test.ts | 31 ++---- 2 files changed, 11 insertions(+), 116 deletions(-) diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 2742d1e55..438db6a6e 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -277,7 +277,7 @@ describe('Authorization Handler', () => { }); describe('Resource parameter validation', () => { - it('accepts valid resource parameter', async () => { + it('propagates resource parameter', async () => { const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); const response = await supertest(app) @@ -302,100 +302,6 @@ describe('Authorization Handler', () => { expect.any(Object) ); }); - - it('rejects invalid resource parameter (non-URL)', async () => { - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'not-a-url' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location); - expect(location.searchParams.get('error')).toBe('invalid_request'); - expect(location.searchParams.get('error_description')).toContain('resource'); - }); - - it('handles authorization without resource parameter', async () => { - const mockProviderWithoutResource = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - expect(mockProviderWithoutResource).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: undefined, - redirectUri: 'https://example.com/callback', - codeChallenge: 'challenge123' - }), - expect.any(Object) - ); - }); - - it('passes multiple resources if provided', async () => { - const mockProviderWithResources = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'https://api1.example.com/resource', - state: 'test-state' - }); - - expect(response.status).toBe(302); - expect(mockProviderWithResources).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: new URL('https://api1.example.com/resource'), - state: 'test-state' - }), - expect.any(Object) - ); - }); - - it('validates resource parameter in POST requests', async () => { - const mockProviderPost = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .post('/authorize') - .type('form') - .send({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(302); - expect(mockProviderPost).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: new URL('https://api.example.com/resource') - }), - expect.any(Object) - ); - }); }); describe('Successful authorization', () => { diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 665ef9261..cf1a93593 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,7 +3,6 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; -import { LATEST_PROTOCOL_VERSION, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -43,13 +42,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(validAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -89,13 +87,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer expired-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -118,13 +115,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -141,7 +137,6 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -151,7 +146,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -173,7 +168,6 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -183,7 +177,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(authInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -232,7 +226,6 @@ describe("requireBearerAuth middleware", () => { it("should return 401 when token verification fails with InvalidTokenError", async () => { mockRequest.headers = { authorization: "Bearer invalid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); @@ -240,7 +233,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -255,7 +248,6 @@ describe("requireBearerAuth middleware", () => { it("should return 403 when access token has insufficient scopes", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); @@ -263,7 +255,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -278,7 +270,6 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when a ServerError occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); @@ -286,7 +277,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal server issue" }) @@ -297,7 +288,6 @@ describe("requireBearerAuth middleware", () => { it("should return 400 for generic OAuthError", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); @@ -305,7 +295,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" }) @@ -316,7 +306,6 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when unexpected error occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); @@ -324,7 +313,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" }) From 6a01d0d4a59e437b135082e68d3923f9b6e9397a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:07:38 +0100 Subject: [PATCH 054/208] shrink token.test.ts --- src/client/auth.ts | 2 +- src/server/auth/handlers/token.test.ts | 68 -------------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 5fa2dee24..fbe50e11d 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -93,7 +93,7 @@ export async function auth( { serverUrl, authorizationCode, scope, - resourceMetadataUrl, + resourceMetadataUrl }: { serverUrl: string | URL; authorizationCode?: string; diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 63b47f53e..dda4e7553 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -307,74 +307,6 @@ describe('Token Handler', () => { ); }); - it('rejects invalid resource parameter (non-URL)', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - resource: 'not-a-url' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('resource'); - }); - - it('handles authorization code exchange without resource parameter', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(200); - expect(mockExchangeCode).toHaveBeenCalledWith( - validClient, - 'valid_code', - undefined, // code_verifier is undefined after PKCE validation - undefined, // redirect_uri - undefined // resource parameter - ); - }); - - it('passes resource with redirect_uri', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - redirect_uri: 'https://example.com/callback', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); - expect(mockExchangeCode).toHaveBeenCalledWith( - validClient, - 'valid_code', - undefined, // code_verifier is undefined after PKCE validation - 'https://example.com/callback', // redirect_uri - new URL('https://api.example.com/resource') // resource parameter - ); - }); - it('passes through code verifier when using proxy provider', async () => { const originalFetch = global.fetch; From 5c60c77c93bb7479affa889ffeb3b6dd5d53233a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:09:00 +0100 Subject: [PATCH 055/208] shrink diff --- src/server/auth/middleware/bearerAuth.test.ts | 22 ------------------- src/server/auth/middleware/bearerAuth.ts | 1 - 2 files changed, 23 deletions(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index cf1a93593..b8953e5c9 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -54,28 +54,6 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.json).not.toHaveBeenCalled(); }); - it("should use default negotiated protocol version when mcp-protocol-version header is missing", async () => { - const validAuthInfo: AuthInfo = { - token: "valid-token", - clientId: "client-123", - scopes: ["read", "write"], - }; - mockVerifyAccessToken.mockResolvedValue(validAuthInfo); - - mockRequest.headers = { - authorization: "Bearer valid-token", - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - expect(mockRequest.auth).toEqual(validAuthInfo); - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index a34625d1e..fd96055ab 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -2,7 +2,6 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; -import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../../../types.js"; export type BearerAuthMiddlewareOptions = { /** From 354318f147c17e5c7ea8072e7d9ada206c393aa3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:14:57 +0100 Subject: [PATCH 056/208] auth: don't fail the prm if the resource doesn't match --- src/client/auth.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index fbe50e11d..297eb9cfc 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -102,19 +102,20 @@ export async function auth( const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - const resourceMetadata = await discoverOAuthProtectedResourceMetadata( - resourceMetadataUrl || serverUrl); - + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + } catch (error) { + console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) + } + if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); } - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } const metadata = await discoverOAuthMetadata(authorizationServerUrl); From bac384f242d96a09a94dff184cc1dcbd927c0bbe Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:23:48 +0100 Subject: [PATCH 057/208] simplify tests --- src/client/auth.test.ts | 60 ----------------------------------------- 1 file changed, 60 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index ec913ecd9..9cdc9e056 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1030,65 +1030,5 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); - - it("verifies resource parameter distinguishes between different paths on same domain", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - 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); - - // Test with different resource paths on same domain - // This tests the security fix that prevents token confusion between - // multiple MCP servers on the same domain - const result1 = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server-1/v1", - }); - - expect(result1).toBe("REDIRECT"); - - const redirectCall1 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl1: URL = redirectCall1[0]; - expect(authUrl1.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-1/v1"); - - // Clear mock calls - (mockProvider.redirectToAuthorization as jest.Mock).mockClear(); - - // Test with different path on same domain - const result2 = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server-2/v1", - }); - - expect(result2).toBe("REDIRECT"); - - const redirectCall2 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl2: URL = redirectCall2[0]; - expect(authUrl2.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-2/v1"); - - // Verify that the two resources are different (critical for security) - expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); - }); }); }); From a7f9c59401a4722b673751a2a3bf21ef91e4eca1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:16:32 +0100 Subject: [PATCH 058/208] Fix SSE test resource URL validation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed 5 instances of hardcoded "https://resource.example.com" in OAuth protected resource metadata mocks to use the actual resourceBaseUrl.href. This resolves test failures where the auth validation was rejecting requests because the resource URL in the metadata didn't match the actual test server URL. The failing tests were: - attempts auth flow on 401 during SSE connection - attempts auth flow on 401 during POST request - refreshes expired token during SSE connection - refreshes expired token during POST request - redirects to authorization if refresh token flow fails All SSE tests now pass (17/17). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/sse.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 714e1fddf..3cb4e8a3c 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -398,7 +398,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -450,7 +450,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -601,7 +601,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -723,7 +723,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -851,7 +851,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; From f0ea31cff96d5aee6bfd1a885dcf267b4be4c188 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:19:56 +0100 Subject: [PATCH 059/208] Update auth.test.ts --- src/client/auth.test.ts | 55 +++-------------------------------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9cdc9e056..cb726717a 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -324,6 +324,7 @@ describe("OAuth Authorization", () => { metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -338,20 +339,8 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:3000/callback" ); - expect(codeVerifier).toBe("test_verifier"); - }); - - it("includes resource parameter when provided", async () => { - const { authorizationUrl } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - resource: new URL("https://api.example.com/mcp-server"), - } - ); - expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(codeVerifier).toBe("test_verifier"); }); it("includes scope parameter when provided", async () => { @@ -478,6 +467,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -500,26 +490,6 @@ describe("OAuth Authorization", () => { expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); - }); - - it("includes resource parameter in token exchange when provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokens, - }); - - const tokens = await exchangeAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - authorizationCode: "code123", - codeVerifier: "verifier123", - redirectUri: "http://localhost:3000/callback", - resource: new URL("https://api.example.com/mcp-server"), - }); - - expect(tokens).toEqual(validTokens); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); @@ -588,6 +558,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -608,24 +579,6 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); - }); - - it("includes resource parameter in refresh token request when provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokensWithNewRefreshToken, - }); - - const tokens = await refreshAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - refreshToken: "refresh123", - resource: new URL("https://api.example.com/mcp-server"), - }); - - expect(tokens).toEqual(validTokensWithNewRefreshToken); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); From 3f07bdb223c2ff0d55f935fd427ad961b1b218cb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:32:45 +0100 Subject: [PATCH 060/208] shrink tests --- src/server/auth/handlers/token.test.ts | 108 ++---------------- .../auth/providers/proxyProvider.test.ts | 72 +----------- 2 files changed, 8 insertions(+), 172 deletions(-) diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index dda4e7553..4b7fae025 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -264,12 +264,14 @@ describe('Token Handler', () => { }); it('returns tokens for valid code exchange', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'authorization_code', code: 'valid_code', code_verifier: 'valid_verifier' @@ -280,24 +282,6 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('mock_refresh_token'); - }); - - it('accepts and passes resource parameter to provider', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); expect(mockExchangeCode).toHaveBeenCalledWith( validClient, 'valid_code', @@ -465,12 +449,14 @@ describe('Token Handler', () => { }); it('returns new tokens for valid refresh token', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token' }); @@ -480,39 +466,6 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('new_mock_refresh_token'); - }); - - it('respects requested scopes on refresh', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - scope: 'profile email' - }); - - expect(response.status).toBe(200); - expect(response.body.scope).toBe('profile email'); - }); - - it('accepts and passes resource parameter to provider on refresh', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); expect(mockExchangeRefresh).toHaveBeenCalledWith( validClient, 'valid_refresh_token', @@ -521,48 +474,7 @@ describe('Token Handler', () => { ); }); - it('rejects invalid resource parameter (non-URL) on refresh', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - resource: 'not-a-url' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('resource'); - }); - - it('handles refresh token exchange without resource parameter', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token' - }); - - expect(response.status).toBe(200); - expect(mockExchangeRefresh).toHaveBeenCalledWith( - validClient, - 'valid_refresh_token', - undefined, // scopes - undefined // resource parameter - ); - }); - - it('passes resource with scopes on refresh', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - + it('respects requested scopes on refresh', async () => { const response = await supertest(app) .post('/token') .type('form') @@ -571,17 +483,11 @@ describe('Token Handler', () => { client_secret: 'valid-secret', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token', - scope: 'profile email', - resource: 'https://api.example.com/resource' + scope: 'profile email' }); expect(response.status).toBe(200); - expect(mockExchangeRefresh).toHaveBeenCalledWith( - validClient, - 'valid_refresh_token', - ['profile', 'email'], // scopes - new URL('https://api.example.com/resource') // resource parameter - ); + expect(response.body.scope).toBe('profile email'); }); }); diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index b834c6592..4e98d0dc0 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: "test-challenge", state: "test-state", scopes: ["read", "write"], + resource: new URL('https://api.example.com/resource'), }, mockResponse ); @@ -100,52 +101,10 @@ describe("Proxy OAuth Server Provider", () => { expectedUrl.searchParams.set("code_challenge_method", "S256"); expectedUrl.searchParams.set("state", "test-state"); expectedUrl.searchParams.set("scope", "read write"); - - expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); - }); - - it('includes resource parameter in authorization redirect', async () => { - await provider.authorize( - validClient, - { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - state: 'test-state', - scopes: ['read', 'write'], - resource: new URL('https://api.example.com/resource') - }, - mockResponse - ); - - const expectedUrl = new URL('https://auth.example.com/authorize'); - expectedUrl.searchParams.set('client_id', 'test-client'); - expectedUrl.searchParams.set('response_type', 'code'); - expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); - expectedUrl.searchParams.set('code_challenge', 'test-challenge'); - expectedUrl.searchParams.set('code_challenge_method', 'S256'); - expectedUrl.searchParams.set('state', 'test-state'); - expectedUrl.searchParams.set('scope', 'read write'); expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); - - it('handles authorization without resource parameter', async () => { - await provider.authorize( - validClient, - { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - state: 'test-state', - scopes: ['read'] - }, - mockResponse - ); - - const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectUrl); - expect(url.searchParams.has('resource')).toBe(false); - }); }); describe("token exchange", () => { @@ -282,35 +241,6 @@ describe("Proxy OAuth Server Provider", () => { ); expect(tokens).toEqual(mockTokenResponse); }); - - it('handles refresh token exchange without resource parameter', async () => { - const tokens = await provider.exchangeRefreshToken( - validClient, - 'test-refresh-token', - ['read'] - ); - - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; - const body = fetchCall[1].body as string; - expect(body).not.toContain('resource='); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('includes both scope and resource parameters in refresh', async () => { - const tokens = await provider.exchangeRefreshToken( - validClient, - 'test-refresh-token', - ['profile', 'email'], - new URL('https://api.example.com/resource') - ); - - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; - const body = fetchCall[1].body as string; - expect(body).toContain('scope=profile+email'); - expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/resource')); - expect(tokens).toEqual(mockTokenResponse); - }); - }); describe("client registration", () => { From e76faf6a18bcd25724649a57b07c410957510192 Mon Sep 17 00:00:00 2001 From: joeyzzeng Date: Wed, 18 Jun 2025 21:34:42 +0800 Subject: [PATCH 061/208] 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 7fb6bd55c..242f05297 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 3d9673da7..9440708d9 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 4b3db9bbebb1fa93e0e59841a3fc8842996ba43f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 15:06:16 +0100 Subject: [PATCH 062/208] stricter PRM check overridable w/ OAuthClientProvider.validateProtectedResourceMetadata --- src/client/auth.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 297eb9cfc..7097eab0b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -72,6 +72,13 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). + * + * Implementations must verify the provider + */ + validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -109,11 +116,13 @@ export async function auth( } catch (error) { console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } - if (resourceMetadata) { + if (provider.validateProtectedResourceMetadata) { + await provider.validateProtectedResourceMetadata(resourceMetadata); + } else if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { + if (resourceMetadata.resource !== resource.href) { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); } } From f854b58443a856ef06f58e33e8f49c40f74d9eb9 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 15:26:58 +0100 Subject: [PATCH 063/208] test validateProtectedResourceMetadata override --- src/client/auth.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index cb726717a..194c11245 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -983,5 +983,66 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); + + it("skips default PRM resource validation when custom validateProtectedResourceMetadata is provided", async () => { + const mockValidateProtectedResourceMetadata = jest.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateProtectedResourceMetadata: mockValidateProtectedResourceMetadata, + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + 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: "https://different-resource.example.com/mcp-server", // Mismatched resource + 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 + (providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify custom validation method was called + expect(mockValidateProtectedResourceMetadata).toHaveBeenCalledWith({ + resource: "https://different-resource.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7097eab0b..4d604d28a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -76,7 +76,7 @@ export interface OAuthClientProvider { /** * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). * - * Implementations must verify the provider + * Implementations must verify the resource matches the MCP server. */ validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; } From dada5f66f570f312a910bae095c481915b2f80c4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 15:52:56 +0100 Subject: [PATCH 064/208] wip helper func --- src/client/auth.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 4d604d28a..bef7965f1 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -78,7 +78,7 @@ export interface OAuthClientProvider { * * Implementations must verify the resource matches the MCP server. */ - validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -107,26 +107,19 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL }): Promise { - const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); - let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) - } - if (provider.validateProtectedResourceMetadata) { - await provider.validateProtectedResourceMetadata(resourceMetadata); - } else if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - if (resourceMetadata.resource !== resource.href) { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); - } + } catch (error) { + console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed @@ -202,6 +195,19 @@ export async function auth( return "REDIRECT"; } +async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + 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 resource; +} + /** * Extract resource_metadata from response header. */ From 4c51230fc2022f15573eea6ec6c7b9f3bbbdea91 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 16:00:12 +0100 Subject: [PATCH 065/208] fix tests --- src/client/auth.test.ts | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 194c11245..91422de0e 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -849,7 +849,7 @@ describe("OAuth Authorization", () => { }); expect(result).toBe("REDIRECT"); - + // Verify the authorization URL includes the resource parameter expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( expect.objectContaining({ @@ -866,7 +866,7 @@ describe("OAuth Authorization", () => { // Mock successful metadata discovery and token exchange mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, @@ -891,7 +891,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -912,11 +912,11 @@ describe("OAuth Authorization", () => { expect(result).toBe("AUTHORIZED"); // Find the token exchange call - const tokenCall = mockFetch.mock.calls.find(call => + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes("/token") ); expect(tokenCall).toBeDefined(); - + const body = tokenCall![1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); expect(body.get("code")).toBe("auth-code-123"); @@ -926,7 +926,7 @@ describe("OAuth Authorization", () => { // Mock successful metadata discovery and token refresh mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, @@ -950,7 +950,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -973,29 +973,29 @@ describe("OAuth Authorization", () => { expect(result).toBe("AUTHORIZED"); // Find the token refresh call - const tokenCall = mockFetch.mock.calls.find(call => + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes("/token") ); expect(tokenCall).toBeDefined(); - + const body = tokenCall![1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); - it("skips default PRM resource validation when custom validateProtectedResourceMetadata is provided", async () => { - const mockValidateProtectedResourceMetadata = jest.fn().mockResolvedValue(undefined); + it("skips default PRM resource validation when custom validateResourceURL is provided", async () => { + const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { ...mockProvider, - validateProtectedResourceMetadata: mockValidateProtectedResourceMetadata, + validateResourceURL: mockValidateResourceURL, }; // Mock protected resource metadata with mismatched resource URL // This would normally throw an error in default validation, but should be skipped mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-protected-resource")) { return Promise.resolve({ ok: true, @@ -1018,7 +1018,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -1037,12 +1037,12 @@ describe("OAuth Authorization", () => { }); expect(result).toBe("REDIRECT"); - + // Verify custom validation method was called - expect(mockValidateProtectedResourceMetadata).toHaveBeenCalledWith({ - resource: "https://different-resource.example.com/mcp-server", - authorization_servers: ["https://auth.example.com"], - }); + expect(mockValidateResourceURL).toHaveBeenCalledWith( + "https://api.example.com/mcp-server", + "https://different-resource.example.com/mcp-server" + ); }); }); }); From 86bed6aaacd4491cbd0621e24836fdcc5cd1ca34 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 16:05:14 +0100 Subject: [PATCH 066/208] adjust comment --- src/client/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index bef7965f1..28d9d8339 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -74,9 +74,11 @@ export interface OAuthClientProvider { codeVerifier(): string | Promise; /** - * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). + * If defined, overrides the selection and validation of the + * RFC 8707 Resource Indicator. If left undefined, default + * validation behavior will be used. * - * Implementations must verify the resource matches the MCP server. + * Implementations must verify the returned resource matches the MCP server. */ validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; } From e7a5e331f1df4d506f526cabce207117ed9084ea Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 18 Jun 2025 20:11:18 +0100 Subject: [PATCH 067/208] rename decline to reject --- README.md | 2 +- src/examples/client/simpleStreamableHttp.ts | 8 ++++---- src/examples/server/simpleStreamableHttp.ts | 6 +++--- src/server/index.test.ts | 10 +++++----- src/server/mcp.test.ts | 2 +- src/types.ts | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9fa07de34..aa8f9304c 100644 --- a/README.md +++ b/README.md @@ -930,7 +930,7 @@ Client-side: Handle elicitation requests ```typescript // This is a placeholder - implement based on your UI framework async function getInputFromUser(message: string, schema: any): Promise<{ - action: "accept" | "decline" | "cancel"; + action: "accept" | "reject" | "cancel"; data?: Record; }> { // This should be implemented depending on the app diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index ddb274196..02db131ef 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -363,7 +363,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return { action: 'reject' }; } } @@ -381,7 +381,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return { action: 'reject' }; } } @@ -408,13 +408,13 @@ async function connect(url?: string): Promise { console.log('Please re-enter the information...'); continue; } else { - return { action: 'decline' }; + return { action: 'reject' }; } } } console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return { action: 'reject' }; }); transport = new StreamableHTTPClientTransport( diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 98d85c948..40e96a44a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -205,12 +205,12 @@ const getServer = () => { }, ], }; - } else if (result.action === 'decline') { + } else if (result.action === 'reject') { return { content: [ { type: 'text', - text: `No information was collected. User declined to provide ${infoType} information.`, + text: `No information was collected. User rejectd to provide ${infoType} information.`, }, ], }; @@ -458,7 +458,7 @@ if (useOAuth) { } const data = await response.json(); - + if (strictOAuth) { if (!data.aud) { throw new Error(`Resource Indicator (RFC8707) missing`); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index ce54247a0..48b7f7340 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -505,7 +505,7 @@ test("should reject elicitation response with invalid data", async () => { ).rejects.toThrow(/does not match requested schema/); }); -test("should allow elicitation decline and cancel without validation", async () => { +test("should allow elicitation reject and cancel without validation", async () => { const server = new Server( { name: "test server", @@ -524,7 +524,7 @@ test("should allow elicitation decline and cancel without validation", async () const client = new Client( { - name: "test client", + name: "test client", version: "1.0", }, { @@ -538,7 +538,7 @@ test("should allow elicitation decline and cancel without validation", async () client.setRequestHandler(ElicitRequestSchema, (request) => { requestCount++; if (requestCount === 1) { - return { action: "decline" }; + return { action: "reject" }; } else { return { action: "cancel" }; } @@ -559,14 +559,14 @@ test("should allow elicitation decline and cancel without validation", async () required: ["name"], }; - // Test decline - should not validate + // Test reject - should not validate await expect( server.elicitInput({ message: "Please provide your name", requestedSchema: schema, }), ).resolves.toEqual({ - action: "decline", + action: "reject", }); // Test cancel - should not validate diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7fb6bd55c..50df25b53 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -4157,7 +4157,7 @@ describe("elicitInput()", () => { // Mock availability check to return false checkAvailability.mockResolvedValue(false); - // Set up client to decline alternative date checking + // Set up client to reject alternative date checking client.setRequestHandler(ElicitRequestSchema, async () => { return { action: "accept", diff --git a/src/types.ts b/src/types.ts index e16b313de..3606a6be7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1237,7 +1237,7 @@ export const ElicitResultSchema = ResultSchema.extend({ /** * The user's response action. */ - action: z.enum(["accept", "decline", "cancel"]), + action: z.enum(["accept", "reject", "cancel"]), /** * The collected user input content (only present if action is "accept"). */ From 54a8556696256dc563596757123cdc628b57910e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 18 Jun 2025 20:21:24 +0100 Subject: [PATCH 068/208] bump version --- 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 c676d07f8..d14ac4f43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.3", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.12.3", + "version": "1.13.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 6b184f31d..4516ef292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.3", + "version": "1.13.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 2f9530bdf9b956c18d0647f757c212b7f7d3ead1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 18 Jun 2025 20:33:35 +0100 Subject: [PATCH 069/208] fix typo --- src/examples/server/simpleStreamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 40e96a44a..37c5f0be7 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -210,7 +210,7 @@ const getServer = () => { content: [ { type: 'text', - text: `No information was collected. User rejectd to provide ${infoType} information.`, + text: `No information was collected. User rejected ${infoType} information request.`, }, ], }; From b293911df72f6191a8262beba4495fcaf80abb08 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 18 Jun 2025 21:56:22 -0600 Subject: [PATCH 070/208] 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 3d9673da7..ee85ab595 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 2a238905df96dda86d84fc6b05971c8e160e8b37 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 19 Jun 2025 11:44:43 +0100 Subject: [PATCH 071/208] simpleStreamableHttp: fix example code (#660) --- .../server/demoInMemoryOAuthProvider.ts | 24 +++++++++---------- src/examples/server/simpleStreamableHttp.ts | 2 +- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index fe8d3f9cf..274a504a1 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -35,17 +35,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params: AuthorizationParams, client: OAuthClientInformationFull}>(); private tokens = new Map(); - private validateResource?: (resource?: URL) => boolean; - - constructor({mcpServerUrl}: {mcpServerUrl?: URL} = {}) { - if (mcpServerUrl) { - const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); - this.validateResource = (resource?: URL) => { - if (!resource) return false; - return resource.toString() === expectedResource.toString(); - }; - } - } + + constructor(private validateResource?: (resource?: URL) => boolean) {} async authorize( client: OAuthClientInformationFull, @@ -153,13 +144,20 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMetadata => { +export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {authServerUrl: URL, mcpServerUrl: URL, strictResource: boolean}): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider({mcpServerUrl}); + + const validateResource = strictResource ? (resource?: URL) => { + if (!resource) return false; + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + return resource.toString() === expectedResource.toString(); + } : undefined; + + const provider = new DemoInMemoryAuthProvider(validateResource); const authApp = express(); authApp.use(express.json()); // For introspection requests diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 37c5f0be7..6406bc213 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -432,7 +432,7 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); + const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth}); const tokenVerifier = { verifyAccessToken: async (token: string) => { From 87da0e0c3a96d9d3bc251d158c42579aeef0b6fd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 19 Jun 2025 17:52:24 +0100 Subject: [PATCH 072/208] 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 b99e4c903..532e13a39 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 28d9d8339..c97d4f0bd 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 6406bc213..09d30da2a 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 c35bb1228..c1fa7bdf1 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 086d812f6..97a77c01d 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 073/208] 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 532e13a39..f95cb2ca8 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 c97d4f0bd..680fefd08 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 744b9eade60424709e7a8b0e6741fbd3306af81f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 19 Jun 2025 18:56:25 +0100 Subject: [PATCH 074/208] build: add watching script targets for build & simple streamable http server (#663) --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4516ef292..bb8022faf 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,11 @@ ], "scripts": { "build": "npm run build:esm && npm run build:cjs", - "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", - "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", + "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json", + "build:cjs:w": "npm run build:cjs -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint src/", "test": "jest", From f4b8a48ded019a54a38d3d150a013427d6cbdbc6 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 19 Jun 2025 22:34:13 -0700 Subject: [PATCH 075/208] 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 515114cf2..d792f015f 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 28d9d8339..f84efa05e 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 f3d440b99..3e8d8ec80 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 0a6283a8b..126ce006b 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 30b7cdf8f..c31373484 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 95e8b4b32..0d1b30e07 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 1d97805bc..b2ab74391 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 fd96055ab..91f763a9b 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 76049c118..ecd9a7b65 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 fc9ca5ff2efc7ee3f54484d2a906d5fdf528bae4 Mon Sep 17 00:00:00 2001 From: sushichan044 <71284054+sushichan044@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:47:53 +0900 Subject: [PATCH 076/208] fix: update ToolCallback type to include output arguments for better type safety --- src/server/mcp.test.ts | 3 +- src/server/mcp.ts | 68 +++++++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 50df25b53..7e299bb08 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1248,8 +1248,7 @@ describe("tool()", () => { processedInput: input, resultType: "structured", // Missing required 'timestamp' field - someExtraField: "unexpected" // Extra field not in schema - }, + } as unknown as { processedInput: string; resultType: string; timestamp: string }, // Type assertion to bypass TypeScript validation for testing purposes }) ); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da7..e48970d67 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -169,7 +169,7 @@ export class McpServer { } const args = parseResult.data; - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(args, extra)); } catch (error) { @@ -184,7 +184,7 @@ export class McpServer { }; } } else { - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(extra)); } catch (error) { @@ -760,7 +760,7 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, - callback: ToolCallback + callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { title, @@ -917,7 +917,7 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; }, - cb: ToolCallback + cb: ToolCallback ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -932,7 +932,7 @@ export class McpServer { inputSchema, outputSchema, annotations, - cb as ToolCallback + cb as ToolCallback ); } @@ -1126,6 +1126,16 @@ export class ResourceTemplate { } } +/** + * Type helper to create a strongly-typed CallToolResult with structuredContent + */ +type TypedCallToolResult = + OutputArgs extends ZodRawShape + ? CallToolResult & { + structuredContent?: z.objectOutputType; + } + : CallToolResult; + /** * Callback for a tool handler registered with Server.tool(). * @@ -1136,13 +1146,21 @@ export class ResourceTemplate { * - `content` if the tool does not have an outputSchema * - Both fields are optional but typically one should be provided */ -export type ToolCallback = - Args extends ZodRawShape +export type ToolCallback< + InputArgs extends undefined | ZodRawShape = undefined, + OutputArgs extends undefined | ZodRawShape = undefined +> = InputArgs extends ZodRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; + args: z.objectOutputType, + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise> + : ( + extra: RequestHandlerExtra + ) => + | TypedCallToolResult + | Promise>; export type RegisteredTool = { title?: string; @@ -1150,22 +1168,24 @@ export type RegisteredTool = { inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; annotations?: ToolAnnotations; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update( - updates: { - name?: string | null, - title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void - remove(): void + update< + InputArgs extends ZodRawShape, + OutputArgs extends ZodRawShape + >(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + callback?: ToolCallback + enabled?: boolean + }): void; + remove(): void; }; const EMPTY_OBJECT_JSON_SCHEMA = { From 08808a45e01d89366548b95af7c1a2159b474cf9 Mon Sep 17 00:00:00 2001 From: sushichan044 <71284054+sushichan044@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:48:05 +0900 Subject: [PATCH 077/208] test: add type assertion to bypass type check --- src/examples/server/mcpServerOutputSchema.ts | 4 ++-- src/server/mcp.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 75bfe6900..dce2d42ef 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -43,7 +43,7 @@ server.registerTool( void country; // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; + const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)] as unknown as "sunny" | "cloudy" | "rainy" | "stormy" | "snowy"; const structuredContent = { temperature: { @@ -77,4 +77,4 @@ async function main() { main().catch((error) => { console.error("Server error:", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7e299bb08..1a17d7894 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1248,6 +1248,7 @@ describe("tool()", () => { processedInput: input, resultType: "structured", // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema } as unknown as { processedInput: string; resultType: string; timestamp: string }, // Type assertion to bypass TypeScript validation for testing purposes }) ); 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 078/208] 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 5c6b72d25..b16db73f3 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 a04f26eb2..942f096ad 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 079/208] 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 5aa99abb4..2546d508a 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 080/208] 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 d14ac4f43..016adf948 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 bb8022faf..0439e6808 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 081/208] 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 d953e1f0a..35105daa4 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 082/208] 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 35105daa4..33a9a6b9b 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 083/208] 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 33a9a6b9b..cba14a9c5 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 084/208] 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 f95cb2ca8..511b351fb 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 085/208] 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 511b351fb..b689d188b 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 cba14a9c5..e0e93fc0e 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 086/208] 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 b689d188b..8e77c0a5b 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 e0e93fc0e..376905743 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); + } + + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; } - return resource; + // 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 087/208] 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 242f05297..e09ab5117 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 9440708d9..67da78ffb 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 088/208] 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 9dd8236bd..016adf948 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 06c0bc8d4..a54e5788f 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 b5f8aca77..807743eb2 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 1114e50b7..3892af6cb 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 ae539c177..33afd70ee 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 089/208] 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 65b800e79..b906de3d7 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 090/208] clean up tests - remove mockRequestInfo --- src/client/index.test.ts | 15 ++++----------- 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, 47 insertions(+), 73 deletions(-) delete mode 100644 src/server/types/types.ts diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 02d6781c9..abd0c34e4 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 137b89348..d91b90a9c 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 d208d51e6..0764ffe88 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 7edef6af0..703cc5146 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 a54e5788f..de4dd60a6 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 807743eb2..677da45ea 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 3892af6cb..000000000 --- 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 33afd70ee..35839a4f8 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 69fce10ed..96b291fab 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 3606a6be7..f66d2c4b6 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 7a64f974936fd27faf063df8f9e952740bce9ad4 Mon Sep 17 00:00:00 2001 From: Rishi Nandha Vanchi <49914358+RishiNandha@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:08:15 +0530 Subject: [PATCH 091/208] Add: Sampling Example to README --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index aa8f9304c..0d8d8f7c9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Tools](#tools) - [Prompts](#prompts) - [Completions](#completions) + - [Sampling](#sampling) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -382,6 +383,37 @@ import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.j const displayName = getDisplayName(tool); ``` +### Sampling + +MCP servers can also request MCP client LLMs for responses. Below is an example of a sampling request sent just after connecting to the Client + +```typescript +// Result sent back from LLM follow the CreateMessageSchema +import {CreateMessageResult} from "@modelcontextprotocol/sdk/types.js"; + +// Async Function to send a sampling request to the LLM at top-level +async function samplingExample(server: McpServer): Promise { + const samplingText = "Text prompt to send to LLM"; + const result = await McpServer.server.createMessage( + { + messages : [{ + role: "user", + content: { + text: samplingText, + type: "text" + } + }], + maxTokens: 1000 + } + ); + return result; +} + +// Sampling request just after connecting to MCP Client +server.connect(transport); +samplingExample(server); +``` + ## Running Your Server MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: From b232faf7987852cd063ec1bda03cba533b5e324e Mon Sep 17 00:00:00 2001 From: Rishi Nandha Vanchi <49914358+RishiNandha@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:09:54 +0530 Subject: [PATCH 092/208] Fixed Tabs --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0d8d8f7c9..4f7ed1067 100644 --- a/README.md +++ b/README.md @@ -393,20 +393,20 @@ import {CreateMessageResult} from "@modelcontextprotocol/sdk/types.js"; // Async Function to send a sampling request to the LLM at top-level async function samplingExample(server: McpServer): Promise { - const samplingText = "Text prompt to send to LLM"; - const result = await McpServer.server.createMessage( - { - messages : [{ - role: "user", - content: { - text: samplingText, - type: "text" - } - }], - maxTokens: 1000 - } - ); - return result; + const samplingText = "Text prompt to send to LLM"; + const result = await McpServer.server.createMessage( + { + messages : [{ + role: "user", + content: { + text: samplingText, + type: "text" + } + }], + maxTokens: 1000 + } + ); + return result; } // Sampling request just after connecting to MCP Client From ca9924b20b52014d0b0e8a2fed143caf2d2e37c5 Mon Sep 17 00:00:00 2001 From: Rishi Nandha Vanchi <49914358+RishiNandha@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:10:57 +0530 Subject: [PATCH 093/208] Changed example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f7ed1067..5e2afd061 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ import {CreateMessageResult} from "@modelcontextprotocol/sdk/types.js"; // Async Function to send a sampling request to the LLM at top-level async function samplingExample(server: McpServer): Promise { - const samplingText = "Text prompt to send to LLM"; + const samplingText = "Example Sampling Prompt"; const result = await McpServer.server.createMessage( { messages : [{ From 5f96ae4d8954daf656a9b69a73b249f748cfc75d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 25 Jun 2025 11:34:44 -0400 Subject: [PATCH 094/208] 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 abf57afb9..de297dc9f 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 095/208] Update package-lock.json --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b5d6e36f..d6e1b9f5a 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 096/208] 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 4aceb734d..32c894f07 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 097/208] 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 d6e1b9f5a..9f1d43a33 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 0439e6808..8feb10aff 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 098/208] 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 a0a06cb9c..a7f180961 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 099/208] 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 f93e7e96e..502435ead 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" }, From fb303d748fe7b05b25d5bc938c43c3ae8871e778 Mon Sep 17 00:00:00 2001 From: sushichan044 <71284054+sushichan044@users.noreply.github.com> Date: Sat, 28 Jun 2025 14:56:02 +0900 Subject: [PATCH 100/208] tidying up --- src/examples/server/mcpServerOutputSchema.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index dce2d42ef..de3b363ed 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -43,7 +43,14 @@ server.registerTool( void country; // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)] as unknown as "sunny" | "cloudy" | "rainy" | "stormy" | "snowy"; + const conditionCandidates = [ + "sunny", + "cloudy", + "rainy", + "stormy", + "snowy", + ] as const; + const conditions = conditionCandidates[Math.floor(Math.random() * conditionCandidates.length)]; const structuredContent = { temperature: { From f43bfccf66cebde336db9051ad40c5029d557f82 Mon Sep 17 00:00:00 2001 From: sunrabbit123 Date: Mon, 30 Jun 2025 13:33:02 +0900 Subject: [PATCH 101/208] test: fix test code for save test case principal Signed-off-by: sunrabbit123 --- src/client/stdio.test.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index cc3731fb6..eb7f0e1b6 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,17 +1,20 @@ import { JSONRPCMessage } from "../types.js"; import { StdioClientTransport, StdioServerParameters, DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from "./stdio.js"; +import { AsyncLocalStorage } from "node:async_hooks"; const serverParameters: StdioServerParameters = { command: "/usr/bin/tee", }; - -let spawnEnv: Record | undefined; +const envAsyncLocalStorage = new AsyncLocalStorage<{ env: Record }>(); jest.mock('cross-spawn', () => { const originalSpawn = jest.requireActual('cross-spawn'); return jest.fn((command, args, options) => { - spawnEnv = options.env; + const env = envAsyncLocalStorage.getStore(); + if (env) { + env.env = options.env; + } return originalSpawn(command, args, options); }); }); @@ -72,6 +75,7 @@ test("should read messages", async () => { }); test("should properly set default environment variables in spawned process", async () => { + await envAsyncLocalStorage.run({ env: {} }, async () => { const client = new StdioClientTransport(serverParameters); await client.start(); @@ -79,18 +83,21 @@ test("should properly set default environment variables in spawned process", asy // Get the default environment variables const defaultEnv = getDefaultEnvironment(); - + const spawnEnv = envAsyncLocalStorage.getStore()?.env; + expect(spawnEnv).toBeDefined(); // Verify that all default environment variables are present for (const key of DEFAULT_INHERITED_ENV_VARS) { if (process.env[key] && !process.env[key].startsWith("()")) { expect(spawnEnv).toHaveProperty(key); expect(spawnEnv![key]).toBe(process.env[key]); - expect(spawnEnv![key]).toBe(defaultEnv[key]); + expect(spawnEnv![key]).toBe(defaultEnv[key]); + } } - } + }); }); test("should override default environment variables with custom ones", async () => { + await envAsyncLocalStorage.run({ env: {} }, async () => { const customEnv = { HOME: "/custom/home", PATH: "/custom/path", @@ -104,7 +111,9 @@ test("should override default environment variables with custom ones", async () await client.start(); await client.close(); - + + const spawnEnv = envAsyncLocalStorage.getStore()?.env; + expect(spawnEnv).toBeDefined(); // Verify that custom environment variables override default ones for (const [key, value] of Object.entries(customEnv)) { expect(spawnEnv).toHaveProperty(key); @@ -117,5 +126,6 @@ test("should override default environment variables with custom ones", async () expect(spawnEnv).toHaveProperty(key); expect(spawnEnv![key]).toBe(process.env[key]); } - } -}); + } + }); +}); From afb128d8c97790303dfe8cfaa0e8f6dc547d2783 Mon Sep 17 00:00:00 2001 From: sunrabbit123 Date: Mon, 30 Jun 2025 13:35:46 +0900 Subject: [PATCH 102/208] fix: should pass environment variables correctly Signed-off-by: sunrabbit123 --- src/client/cross-spawn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/cross-spawn.test.ts b/src/client/cross-spawn.test.ts index 98454a9ae..724ec7066 100644 --- a/src/client/cross-spawn.test.ts +++ b/src/client/cross-spawn.test.ts @@ -73,8 +73,8 @@ describe("StdioClientTransport using cross-spawn", () => { [], expect.objectContaining({ env: { + ...getDefaultEnvironment(), ...customEnv, - ...getDefaultEnvironment() } }) ); From 3d6acd3d9ac50eaed334b5c96e16ee033f2abbd8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 30 Jun 2025 16:23:19 +0100 Subject: [PATCH 103/208] add an example and update readme --- README.md | 77 +++++++++++++++------ src/examples/server/toolWithSampleServer.ts | 57 +++++++++++++++ 2 files changed, 111 insertions(+), 23 deletions(-) create mode 100644 src/examples/server/toolWithSampleServer.ts diff --git a/README.md b/README.md index 5e2afd061..f8143501c 100644 --- a/README.md +++ b/README.md @@ -385,35 +385,66 @@ const displayName = getDisplayName(tool); ### Sampling -MCP servers can also request MCP client LLMs for responses. Below is an example of a sampling request sent just after connecting to the Client +MCP servers can request LLM completions from connected clients that support sampling. ```typescript -// Result sent back from LLM follow the CreateMessageSchema -import {CreateMessageResult} from "@modelcontextprotocol/sdk/types.js"; - -// Async Function to send a sampling request to the LLM at top-level -async function samplingExample(server: McpServer): Promise { - const samplingText = "Example Sampling Prompt"; - const result = await McpServer.server.createMessage( - { - messages : [{ - role: "user", - content: { - text: samplingText, - type: "text" - } - }], - maxTokens: 1000 - } - ); - return result; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const mcpServer = new McpServer({ + name: "tools-with-sample-server", + version: "1.0.0", +}); + +// Tool that uses LLM sampling to summarize any text +mcpServer.registerTool( + "summarize", + { + description: "Summarize any text using an LLM", + inputSchema: { + text: z.string().describe("Text to summarize"), + }, + }, + async ({ text }) => { + // Call the LLM through MCP sampling + const response = await mcpServer.server.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text concisely:\n\n${text}`, + }, + }, + ], + maxTokens: 500, + }); + + return { + content: [ + { + type: "text", + text: response.content.type === "text" ? response.content.text : "Unable to generate summary", + }, + ], + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.log("MCP server is running..."); } -// Sampling request just after connecting to MCP Client -server.connect(transport); -samplingExample(server); +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); ``` + ## Running Your Server MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts new file mode 100644 index 000000000..7e92f33b3 --- /dev/null +++ b/src/examples/server/toolWithSampleServer.ts @@ -0,0 +1,57 @@ + +// Run with: npx tsx src/examples/server/toolWithSampleServer.ts + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const mcpServer = new McpServer({ + name: "tools-with-sample-server", + version: "1.0.0", +}); + +// Tool that uses LLM sampling to summarize any text +mcpServer.registerTool( + "summarize", + { + description: "Summarize any text using an LLM", + inputSchema: { + text: z.string().describe("Text to summarize"), + }, + }, + async ({ text }) => { + // Call the LLM through MCP sampling + const response = await mcpServer.server.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text concisely:\n\n${text}`, + }, + }, + ], + maxTokens: 500, + }); + + return { + content: [ + { + type: "text", + text: response.content.type === "text" ? response.content.text : "Unable to generate summary", + }, + ], + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.log("MCP server is running..."); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file From a8fa0b342ac0411d0827e48820400e5d048baa45 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 30 Jun 2025 16:25:26 +0100 Subject: [PATCH 104/208] build --- src/examples/server/toolWithSampleServer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index 7e92f33b3..44e5cecbb 100644 --- a/src/examples/server/toolWithSampleServer.ts +++ b/src/examples/server/toolWithSampleServer.ts @@ -1,8 +1,8 @@ // Run with: npx tsx src/examples/server/toolWithSampleServer.ts -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { McpServer } from "../../server/mcp.js"; +import { StdioServerTransport } from "../../server/stdio.js"; import { z } from "zod"; const mcpServer = new McpServer({ From 6f5e53b44b7c3a7db80d2f204e713c820e627492 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 2 Jul 2025 01:24:27 +1000 Subject: [PATCH 105/208] Returning undefined from `discoverOAuthMetadata` for CORS errors (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Returning undefined from `discoverOAuthMetadata` for CORS errors This behaviour was already happening for the root URL, but the new `fetchWithCorsRetry` logic differed. The issue was if the server returns a 404 for `/.well-known/oauth-authorization-server/xyz` that didn't have the `access-control-allow-origin`, a TypeError was being thrown. There was logic there already to handle a TypeError for a _preflight_ request (cause by custom headers), but not the fallback. I refactored so all combinations return `undefined`. * Add test for CORS error handling that should return undefined This test covers the scenario where both the initial request with headers and the retry without headers fail with CORS TypeErrors. The desired behavior is to return undefined instead of throwing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix test comment --------- Co-authored-by: Glen Maddern Co-authored-by: Paul Carleton Co-authored-by: Claude Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 13 ++++++++++++ src/client/auth.ts | 47 +++++++++++++++++++---------------------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8e77c0a5b..8155e1342 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -403,6 +403,19 @@ describe("OAuth Authorization", () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + it("returns undefined when both CORS requests fail in fetchWithCorsRetry", async () => { + // fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS) + // simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError + mockFetch.mockImplementation(() => { + // Both the initial request with headers and retry without headers fail with CORS TypeError + return Promise.reject(new TypeError("Failed to fetch")); + }); + + // This should return undefined (the desired behavior after the fix) + const metadata = await discoverOAuthMetadata("https://auth.example.com/path"); + expect(metadata).toBeUndefined(); + }); + it("returns undefined when discovery endpoint returns 404", async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/src/client/auth.ts b/src/client/auth.ts index 376905743..71101a428 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -292,25 +292,24 @@ export async function discoverOAuthProtectedResourceMetadata( return OAuthProtectedResourceMetadataSchema.parse(await response.json()); } -/** - * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. - * - * 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 { + 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); + if (headers) { + // CORS errors come back as TypeError, retry without headers + return fetchWithCorsRetry(url) + } else { + // We're getting CORS errors on retry too, return undefined + return undefined + } } throw error; } @@ -334,7 +333,7 @@ function buildWellKnownPath(pathname: string): string { async function tryMetadataDiscovery( url: URL, protocolVersion: string, -): Promise { +): Promise { const headers = { "MCP-Protocol-Version": protocolVersion }; @@ -344,10 +343,16 @@ async function tryMetadataDiscovery( /** * Determines if fallback to root discovery should be attempted */ -function shouldAttemptFallback(response: Response, pathname: string): boolean { - return response.status === 404 && pathname !== '/'; +function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { + return !response || response.status === 404 && pathname !== '/'; } +/** + * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + */ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, @@ -362,18 +367,10 @@ export async function discoverOAuthMetadata( // 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 { - // If fallback fails, return undefined - return undefined; - } - } else if (response.status === 404) { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + } + if (!response || response.status === 404) { return undefined; } From cfec7d9b39363495c9d5ca77e1b489f41bb262d3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 16:43:50 +0100 Subject: [PATCH 106/208] 1.13.3 --- 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 5f7cb0f55..16b90a3b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 31bc3562c..e50619668 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.2", + "version": "1.13.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From e5167cbe2004e12cedbcaa3d2ba444f13ef80273 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:20:28 -0400 Subject: [PATCH 107/208] Update streamableHttp custom fetch test --- src/client/sse.test.ts | 30 ++++++++++++++++++++++++++++++ src/client/sse.ts | 13 +++++++++++-- src/client/streamableHttp.test.ts | 29 +++++++++++++++++++++++++++++ src/client/streamableHttp.ts | 15 ++++++++++++--- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 3cb4e8a3c..c446b9e92 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -262,6 +262,36 @@ describe("SSEClientTransport", () => { expect(lastServerRequest.headers.authorization).toBe(authToken); }); + it("uses custom fetch implementation from options", async () => { + const authToken = "Bearer custom-token"; + + const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", authToken); + return fetch(url.toString(), { ...init, headers }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + fetch: fetchWithAuth, + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe(authToken); + + // Send a message to verify fetchWithAuth used for POST as well + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "1", + method: "test", + params: {}, + }; + + await transport.send(message); + + expect(fetchWithAuth).toHaveBeenCalled(); + }); + it("passes custom headers to fetch requests", async () => { const customHeaders = { Authorization: "Bearer test-token", diff --git a/src/client/sse.ts b/src/client/sse.ts index 2546d508a..5ca724b17 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -3,6 +3,8 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + export class SseError extends Error { constructor( public readonly code: number | undefined, @@ -47,6 +49,11 @@ export type SSEClientTransportOptions = { * Customizes recurring POST requests to the server. */ requestInit?: RequestInit; + + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; }; /** @@ -62,6 +69,7 @@ export class SSEClientTransport implements Transport { private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; + private _fetch?: FetchLike; private _protocolVersion?: string; onclose?: () => void; @@ -77,6 +85,7 @@ export class SSEClientTransport implements Transport { this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; } private async _authThenStart(): Promise { @@ -117,7 +126,7 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { - const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch + const fetchImpl = (this?._eventSourceInit?.fetch || this._fetch || fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, @@ -242,7 +251,7 @@ export class SSEClientTransport implements Transport { signal: this._abortController?.signal, }; - const response = await fetch(this._endpoint, init); + const response = await (this._fetch ?? fetch)(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 11dfe7d41..be70b8b4a 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -443,6 +443,35 @@ describe("StreamableHTTPClientTransport", () => { expect(errorSpy).toHaveBeenCalled(); }); + it("uses custom fetch implementation", async () => { + const authToken = "Bearer custom-token"; + + const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", authToken); + return (global.fetch as jest.Mock)(url, { ...init, headers }); + }); + + (global.fetch as jest.Mock) + .mockResolvedValueOnce( + new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })); + + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { fetch: fetchWithAuth }); + + await transport.start(); + await (transport as unknown as { _startOrAuthSse: (opts: any) => Promise })._startOrAuthSse({}); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {}, id: "1" } as JSONRPCMessage); + + expect(fetchWithAuth).toHaveBeenCalled(); + for (const call of (global.fetch as jest.Mock).mock.calls) { + const headers = call[1].headers as Headers; + expect(headers.get("Authorization")).toBe(authToken); + } + }); + it("should always send specified custom headers", async () => { const requestInit = { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 730784422..b73f67b95 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -3,6 +3,8 @@ import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPC import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { initialReconnectionDelay: 1000, @@ -99,6 +101,11 @@ export type StreamableHTTPClientTransportOptions = { */ requestInit?: RequestInit; + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; + /** * Options to configure the reconnection behavior. */ @@ -122,6 +129,7 @@ export class StreamableHTTPClientTransport implements Transport { private _resourceMetadataUrl?: URL; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; + private _fetch?: FetchLike; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; @@ -138,6 +146,7 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = undefined; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } @@ -200,7 +209,7 @@ export class StreamableHTTPClientTransport implements Transport { headers.set("last-event-id", resumptionToken); } - const response = await fetch(this._url, { + const response = await (this._fetch ?? fetch)(this._url, { method: "GET", headers, signal: this._abortController?.signal, @@ -414,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport { signal: this._abortController?.signal, }; - const response = await fetch(this._url, init); + const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get("mcp-session-id"); @@ -520,7 +529,7 @@ export class StreamableHTTPClientTransport implements Transport { signal: this._abortController?.signal, }; - const response = await fetch(this._url, init); + const response = await (this._fetch ?? fetch)(this._url, init); // We specifically handle 405 as a valid response according to the spec, // meaning the server does not support explicit session termination From 3300a43742baebc60f4b715628f49b298e33af69 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:33:31 -0400 Subject: [PATCH 108/208] Update src/client/sse.ts nullish coalescing operator ?? instead of the logical OR operator || to handle the case where this?._eventSourceInit?.fetch or this._fetch might be null or undefined but not falsy Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/client/sse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5ca724b17..808aa24b5 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -126,7 +126,7 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { - const fetchImpl = (this?._eventSourceInit?.fetch || this._fetch || fetch) as typeof fetch +const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, From a3c4e28138753696498647e9a8ed51b7b9e0045f Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:33:39 -0400 Subject: [PATCH 109/208] Update src/client/streamableHttp.ts nullish coalescing operator ?? instead of the logical OR operator || to handle the case where this?._eventSourceInit?.fetch or this._fetch might be null or undefined but not falsy Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/client/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index b73f67b95..af884b26a 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -209,7 +209,7 @@ export class StreamableHTTPClientTransport implements Transport { headers.set("last-event-id", resumptionToken); } - const response = await (this._fetch ?? fetch)(this._url, { +const response = await (this._fetch ?? fetch)(this._url, { method: "GET", headers, signal: this._abortController?.signal, From 8e02df150e88098cf5bd63b82024933ac8ccd98f Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:33:48 -0400 Subject: [PATCH 110/208] Update src/client/sse.ts nullish coalescing operator ?? instead of the logical OR operator || to handle the case where this?._eventSourceInit?.fetch or this._fetch might be null or undefined but not falsy Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/client/sse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 808aa24b5..af04f274f 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -251,7 +251,7 @@ const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typ signal: this._abortController?.signal, }; - const response = await (this._fetch ?? fetch)(this._endpoint, init); +const response = await (this._fetch ?? fetch)(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { From 7d8c0610283f5f1c6a7132a8ed50fc6c4eafd3e6 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:33:56 -0400 Subject: [PATCH 111/208] Update src/client/streamableHttp.ts nullish coalescing operator ?? instead of the logical OR operator || to handle the case where this?._eventSourceInit?.fetch or this._fetch might be null or undefined but not falsy Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/client/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index af884b26a..d28eae869 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -423,7 +423,7 @@ const response = await (this._fetch ?? fetch)(this._url, { signal: this._abortController?.signal, }; - const response = await (this._fetch ?? fetch)(this._url, init); +const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get("mcp-session-id"); From 242c8248f73078c62883e49b1dcdbc0727e95319 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 1 Jul 2025 13:34:04 -0400 Subject: [PATCH 112/208] Update src/client/streamableHttp.ts nullish coalescing operator ?? instead of the logical OR operator || to handle the case where this?._eventSourceInit?.fetch or this._fetch might be null or undefined but not falsy Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/client/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index d28eae869..ceaecd833 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -529,7 +529,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); signal: this._abortController?.signal, }; - const response = await (this._fetch ?? fetch)(this._url, init); +const response = await (this._fetch ?? fetch)(this._url, init); // We specifically handle 405 as a valid response according to the spec, // meaning the server does not support explicit session termination From f383a9cb8cbcebd9057e0eb2ec1dc22b250c8970 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 1 Jul 2025 14:00:56 -0400 Subject: [PATCH 113/208] * In sse.test.ts - More specific expectations in test "uses custom fetch implementation from options" * In sse.ts - Import FetchLike from transport.ts * In steramableHttp.ts - Import FetchLike from transport.ts - Export StartSSEOptions interface for testing * In streamableHttp.test.ts - import StartSSEOptions from streamableHttp.ts - use StartSSEOptions instead of any in test "uses custom fetch implementation" * In transport.ts - Add FetchLike function type --- src/client/sse.test.ts | 4 +++- src/client/sse.ts | 4 +--- src/client/streamableHttp.test.ts | 6 +++--- src/client/streamableHttp.ts | 12 +++++------- src/shared/transport.ts | 10 ++++++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index c446b9e92..2d1163449 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -289,7 +289,9 @@ describe("SSEClientTransport", () => { await transport.send(message); - expect(fetchWithAuth).toHaveBeenCalled(); + expect(fetchWithAuth).toHaveBeenCalledTimes(2); + expect(lastServerRequest.method).toBe("POST"); + expect(lastServerRequest.headers.authorization).toBe(authToken); }); it("passes custom headers to fetch requests", async () => { diff --git a/src/client/sse.ts b/src/client/sse.ts index af04f274f..faffecc41 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,10 +1,8 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; -import { Transport } from "../shared/transport.js"; +import { Transport, FetchLike } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - export class SseError extends Error { constructor( public readonly code: number | undefined, diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index be70b8b4a..dcd76528d 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,4 +1,4 @@ -import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; +import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js"; import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { JSONRPCMessage } from "../types.js"; @@ -461,7 +461,7 @@ describe("StreamableHTTPClientTransport", () => { transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { fetch: fetchWithAuth }); await transport.start(); - await (transport as unknown as { _startOrAuthSse: (opts: any) => Promise })._startOrAuthSse({}); + await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}); await transport.send({ jsonrpc: "2.0", method: "test", params: {}, id: "1" } as JSONRPCMessage); @@ -559,7 +559,7 @@ describe("StreamableHTTPClientTransport", () => { // Second retry - should double (2^1 * 100 = 200) expect(getDelay(1)).toBe(200); - // Third retry - should double again (2^2 * 100 = 400) + // Third retry - should double again (2^2 * 100 = 400) expect(getDelay(2)).toBe(400); // Fourth retry - should double again (2^3 * 100 = 800) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ceaecd833..b81f1a5d8 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,10 +1,8 @@ -import { Transport } from "../shared/transport.js"; +import { Transport, FetchLike } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; - // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { initialReconnectionDelay: 1000, @@ -25,7 +23,7 @@ export class StreamableHTTPError extends Error { /** * Options for starting or authenticating an SSE connection */ -interface StartSSEOptions { +export interface StartSSEOptions { /** * The resumption token used to continue long-running requests that were interrupted. * @@ -260,15 +258,15 @@ const response = await (this._fetch ?? fetch)(this._url, { 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 }; } diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 96b291fab..386b6bae5 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,10 +1,12 @@ import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js"; +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + /** * Options for sending a JSON-RPC message. */ export type TransportSendOptions = { - /** + /** * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ relatedRequestId?: RequestId; @@ -38,7 +40,7 @@ export interface Transport { /** * Sends a JSON-RPC message (request or response). - * + * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; @@ -64,9 +66,9 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. - * + * * Includes the requestInfo and authInfo if the transport is authenticated. - * + * * The requestInfo can be used to get the original request information (headers, etc.) */ onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; From a45e4febd94b99b4bccac23a737c1ca39610e2d4 Mon Sep 17 00:00:00 2001 From: Kentaro Suzuki <71284054+sushichan044@users.noreply.github.com> Date: Thu, 3 Jul 2025 22:13:24 +0900 Subject: [PATCH 114/208] Revert "fix: add type safety for tool output schemas in ToolCallback" --- src/examples/server/mcpServerOutputSchema.ts | 11 +--- src/server/mcp.test.ts | 2 +- src/server/mcp.ts | 68 +++++++------------- 3 files changed, 27 insertions(+), 54 deletions(-) diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index de3b363ed..75bfe6900 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -43,14 +43,7 @@ server.registerTool( void country; // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditionCandidates = [ - "sunny", - "cloudy", - "rainy", - "stormy", - "snowy", - ] as const; - const conditions = conditionCandidates[Math.floor(Math.random() * conditionCandidates.length)]; + const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; const structuredContent = { temperature: { @@ -84,4 +77,4 @@ async function main() { main().catch((error) => { console.error("Server error:", error); process.exit(1); -}); +}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index dc96a1b0f..10e550df4 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1312,7 +1312,7 @@ describe("tool()", () => { resultType: "structured", // Missing required 'timestamp' field someExtraField: "unexpected" // Extra field not in schema - } as unknown as { processedInput: string; resultType: string; timestamp: string }, // Type assertion to bypass TypeScript validation for testing purposes + }, }) ); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index a5624e153..791facef1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -169,7 +169,7 @@ export class McpServer { } const args = parseResult.data; - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(args, extra)); } catch (error) { @@ -184,7 +184,7 @@ export class McpServer { }; } } else { - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ToolCallback; try { result = await Promise.resolve(cb(extra)); } catch (error) { @@ -772,7 +772,7 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, - callback: ToolCallback + callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { title, @@ -929,7 +929,7 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; }, - cb: ToolCallback + cb: ToolCallback ): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -944,7 +944,7 @@ export class McpServer { inputSchema, outputSchema, annotations, - cb as ToolCallback + cb as ToolCallback ); } @@ -1138,16 +1138,6 @@ export class ResourceTemplate { } } -/** - * Type helper to create a strongly-typed CallToolResult with structuredContent - */ -type TypedCallToolResult = - OutputArgs extends ZodRawShape - ? CallToolResult & { - structuredContent?: z.objectOutputType; - } - : CallToolResult; - /** * Callback for a tool handler registered with Server.tool(). * @@ -1158,21 +1148,13 @@ type TypedCallToolResult = * - `content` if the tool does not have an outputSchema * - Both fields are optional but typically one should be provided */ -export type ToolCallback< - InputArgs extends undefined | ZodRawShape = undefined, - OutputArgs extends undefined | ZodRawShape = undefined -> = InputArgs extends ZodRawShape +export type ToolCallback = + Args extends ZodRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra - ) => - | TypedCallToolResult - | Promise> - : ( - extra: RequestHandlerExtra - ) => - | TypedCallToolResult - | Promise>; + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => CallToolResult | Promise + : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { title?: string; @@ -1180,24 +1162,22 @@ export type RegisteredTool = { inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; annotations?: ToolAnnotations; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update< - InputArgs extends ZodRawShape, - OutputArgs extends ZodRawShape - >(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - callback?: ToolCallback - enabled?: boolean - }): void; - remove(): void; + update( + updates: { + name?: string | null, + title?: string, + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void + remove(): void }; const EMPTY_OBJECT_JSON_SCHEMA = { From 25048b9eaf8410a97833807dba7039f045ba7414 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 3 Jul 2025 15:51:16 +0100 Subject: [PATCH 115/208] rename reject to decline --- README.md | 2 +- src/examples/client/simpleStreamableHttp.ts | 8 ++++---- src/examples/server/simpleStreamableHttp.ts | 8 ++++---- src/server/index.test.ts | 4 ++-- src/types.ts | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5cc1f4b29..ac10e8cb0 100644 --- a/README.md +++ b/README.md @@ -1041,7 +1041,7 @@ Client-side: Handle elicitation requests ```typescript // This is a placeholder - implement based on your UI framework async function getInputFromUser(message: string, schema: any): Promise<{ - action: "accept" | "reject" | "cancel"; + action: "accept" | "decline" | "cancel"; data?: Record; }> { // This should be implemented depending on the app diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 02db131ef..ddb274196 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -363,7 +363,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'reject' }; + return { action: 'decline' }; } } @@ -381,7 +381,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'reject' }; + return { action: 'decline' }; } } @@ -408,13 +408,13 @@ async function connect(url?: string): Promise { console.log('Please re-enter the information...'); continue; } else { - return { action: 'reject' }; + return { action: 'decline' }; } } } console.log('Maximum attempts reached. Declining request.'); - return { action: 'reject' }; + return { action: 'decline' }; }); transport = new StreamableHTTPClientTransport( diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index fea0eec07..7adab7db1 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -206,12 +206,12 @@ const getServer = () => { }, ], }; - } else if (result.action === 'reject') { + } else if (result.action === 'decline') { return { content: [ { type: 'text', - text: `No information was collected. User rejected ${infoType} information request.`, + text: `No information was collected. User declined ${infoType} information request.`, }, ], }; @@ -433,7 +433,7 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth}); + const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth }); const tokenVerifier = { verifyAccessToken: async (token: string) => { @@ -499,7 +499,7 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; - console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body); + console.log(sessionId ? `Received MCP request for session: ${sessionId}` : 'Received MCP request:', req.body); if (useOAuth && req.auth) { console.log('Authenticated user:', req.auth); } diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d91b90a9c..46205d726 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -538,7 +538,7 @@ test("should allow elicitation reject and cancel without validation", async () = client.setRequestHandler(ElicitRequestSchema, (request) => { requestCount++; if (requestCount === 1) { - return { action: "reject" }; + return { action: "decline" }; } else { return { action: "cancel" }; } @@ -566,7 +566,7 @@ test("should allow elicitation reject and cancel without validation", async () = requestedSchema: schema, }), ).resolves.toEqual({ - action: "reject", + action: "decline", }); // Test cancel - should not validate diff --git a/src/types.ts b/src/types.ts index f66d2c4b6..b96ab0500 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1238,7 +1238,7 @@ export const ElicitResultSchema = ResultSchema.extend({ /** * The user's response action. */ - action: z.enum(["accept", "reject", "cancel"]), + action: z.enum(["accept", "decline", "cancel"]), /** * The collected user input content (only present if action is "accept"). */ From d868c4a1470bdf29012a5e6abed73a9f689a6369 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 3 Jul 2025 16:01:14 +0100 Subject: [PATCH 116/208] build --- src/examples/server/simpleStreamableHttp.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 7adab7db1..3d5235430 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -499,7 +499,12 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { 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 (sessionId) { + console.log(`Received MCP request for session: ${sessionId}`); + } else { + console.log('Request body:', req.body); + } + if (useOAuth && req.auth) { console.log('Authenticated user:', req.auth); } From fc197e01f837971f997f1f57d853e127b7611ac2 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 3 Jul 2025 16:09:00 +0100 Subject: [PATCH 117/208] 1.14.0 --- 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 16b90a3b7..f2c8cbfa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.3", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.3", + "version": "1.14.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index e50619668..15b7753be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.3", + "version": "1.14.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From ead6fb50460a194dcfe8b4348ad3f3e8a2b9a218 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 19:05:02 +0100 Subject: [PATCH 118/208] Add type compatibility test between SDK and spec types --- .gitignore | 3 + package.json | 3 +- src/spec.types.test.ts | 657 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 src/spec.types.test.ts diff --git a/.gitignore b/.gitignore index 6c4bf1a6b..b39dd94e9 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ web_modules/ # Output of 'npm pack' *.tgz +# Output of 'npm run fetch:spec-types' +src/spec.types.ts + # Yarn Integrity file .yarn-integrity diff --git a/package.json b/package.json index 15b7753be..bbca197b4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dist" ], "scripts": { + "fetch:spec-types": "test -f src/spec.types.ts || curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", "build": "npm run build:esm && npm run build:cjs", "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", "build:esm:w": "npm run build:esm -- -w", @@ -43,7 +44,7 @@ "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint src/", - "test": "jest", + "test": "npm run fetch:spec-types && jest", "start": "npm run server", "server": "tsx watch --clear-screen=false src/cli.ts server", "client": "tsx src/cli.ts client" diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts new file mode 100644 index 000000000..b6c886896 --- /dev/null +++ b/src/spec.types.test.ts @@ -0,0 +1,657 @@ +// import ts from 'typescript'; + +import * as SDKTypes from "./types.js"; +import * as SpecTypes from "./spec.types.js"; + +// Deep version that recursively removes index signatures (caused by ZodObject.passthrough()) and turns unknowns into `object | undefined` +type DeepKnownKeys = T extends object + ? T extends Array + ? Array> + : T extends Function + ? T + : { + [K in keyof T as string extends K ? never : number extends K ? never : K]: DeepKnownKeys; + } + : unknown extends T + ? (object | undefined) + : T; + +function checkCancelledNotification( + sdk: SDKTypes.CancelledNotification, + spec: SpecTypes.CancelledNotification +) { + sdk = spec; + spec = sdk; +} +function checkBaseMetadata( + sdk: SDKTypes.BaseMetadata, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkImplementation( + sdk: SDKTypes.Implementation, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkProgressNotification( + sdk: SDKTypes.ProgressNotification, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} + +function checkSubscribeRequest( + sdk: SDKTypes.SubscribeRequest, + spec: SpecTypes.SubscribeRequest +) { + sdk = spec; + spec = sdk; +} +function checkUnsubscribeRequest( + sdk: SDKTypes.UnsubscribeRequest, + spec: SpecTypes.UnsubscribeRequest +) { + sdk = spec; + spec = sdk; +} +function checkPaginatedRequest( + sdk: SDKTypes.PaginatedRequest, + spec: SpecTypes.PaginatedRequest +) { + sdk = spec; + spec = sdk; +} +function checkPaginatedResult( + sdk: SDKTypes.PaginatedResult, + spec: SpecTypes.PaginatedResult +) { + sdk = spec; + spec = sdk; +} +function checkListRootsRequest( + sdk: SDKTypes.ListRootsRequest, + spec: SpecTypes.ListRootsRequest +) { + sdk = spec; + spec = sdk; +} +function checkListRootsResult( + sdk: SDKTypes.ListRootsResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkRoot(sdk: SDKTypes.Root, spec: DeepKnownKeys) { + sdk = spec; + spec = sdk; +} +function checkElicitRequest(sdk: SDKTypes.ElicitRequest, spec: DeepKnownKeys) { + sdk = spec; + spec = sdk; +} +function checkElicitResult(sdk: SDKTypes.ElicitResult, spec: DeepKnownKeys) { + sdk = spec; + spec = sdk; +} +function checkCompleteRequest(sdk: SDKTypes.CompleteRequest, spec: DeepKnownKeys) { + sdk = spec; + spec = sdk; +} +function checkCompleteResult(sdk: SDKTypes.CompleteResult, spec: SpecTypes.CompleteResult) { + sdk = spec; + spec = sdk; +} +function checkProgressToken( + sdk: SDKTypes.ProgressToken, + spec: SpecTypes.ProgressToken +) { + sdk = spec; + spec = sdk; +} +function checkCursor(sdk: SDKTypes.Cursor, spec: SpecTypes.Cursor) { + sdk = spec; + spec = sdk; +} +function checkRequest( + sdk: SDKTypes.Request, + spec: SpecTypes.Request +) { + sdk = spec; + spec = sdk; +} +function checkResult( + sdk: SDKTypes.Result, + spec: SpecTypes.Result +) { + sdk = spec; + spec = sdk; +} +function checkRequestId( + sdk: SDKTypes.RequestId, + spec: SpecTypes.RequestId +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCRequest( + sdk: SDKTypes.JSONRPCRequest, + spec: SpecTypes.JSONRPCRequest +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCNotification( + sdk: SDKTypes.JSONRPCNotification, + spec: SpecTypes.JSONRPCNotification +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCResponse( + sdk: SDKTypes.JSONRPCResponse, + spec: SpecTypes.JSONRPCResponse +) { + sdk = spec; + spec = sdk; +} +function checkEmptyResult( + sdk: SDKTypes.EmptyResult, + spec: SpecTypes.EmptyResult +) { + sdk = spec; + spec = sdk; +} +function checkNotification( + sdk: SDKTypes.Notification, + spec: SpecTypes.Notification +) { + sdk = spec; + spec = sdk; +} +function checkClientResult( + sdk: SDKTypes.ClientResult, + spec: SpecTypes.ClientResult +) { + sdk = spec; + spec = sdk; +} +function checkClientNotification( + sdk: SDKTypes.ClientNotification, + spec: SpecTypes.ClientNotification +) { + sdk = spec; + spec = sdk; +} +function checkServerResult( + sdk: SDKTypes.ServerResult, + spec: SpecTypes.ServerResult +) { + sdk = spec; + spec = sdk; +} +function checkResourceTemplateReference( + sdk: SDKTypes.ResourceTemplateReference, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkPromptReference( + sdk: SDKTypes.PromptReference, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkResourceReference( + sdk: SDKTypes.ResourceReference, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkToolAnnotations( + sdk: SDKTypes.ToolAnnotations, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkTool( + sdk: SDKTypes.Tool, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkListToolsRequest( + sdk: SDKTypes.ListToolsRequest, + spec: SpecTypes.ListToolsRequest +) { + sdk = spec; + spec = sdk; +} +function checkListToolsResult( + sdk: SDKTypes.ListToolsResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkCallToolResult( + sdk: SDKTypes.CallToolResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkCallToolRequest( + sdk: SDKTypes.CallToolRequest, + spec: SpecTypes.CallToolRequest +) { + sdk = spec; + spec = sdk; +} +function checkToolListChangedNotification( + sdk: SDKTypes.ToolListChangedNotification, + spec: SpecTypes.ToolListChangedNotification +) { + sdk = spec; + spec = sdk; +} +function checkResourceListChangedNotification( + sdk: SDKTypes.ResourceListChangedNotification, + spec: SpecTypes.ResourceListChangedNotification +) { + sdk = spec; + spec = sdk; +} +function checkPromptListChangedNotification( + sdk: SDKTypes.PromptListChangedNotification, + spec: SpecTypes.PromptListChangedNotification +) { + sdk = spec; + spec = sdk; +} +function checkRootsListChangedNotification( + sdk: SDKTypes.RootsListChangedNotification, + spec: SpecTypes.RootsListChangedNotification +) { + sdk = spec; + spec = sdk; +} +function checkResourceUpdatedNotification( + sdk: SDKTypes.ResourceUpdatedNotification, + spec: SpecTypes.ResourceUpdatedNotification +) { + sdk = spec; + spec = sdk; +} +function checkSamplingMessage( + sdk: SDKTypes.SamplingMessage, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkCreateMessageResult( + sdk: SDKTypes.CreateMessageResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkSetLevelRequest( + sdk: SDKTypes.SetLevelRequest, + spec: SpecTypes.SetLevelRequest +) { + sdk = spec; + spec = sdk; +} +function checkPingRequest( + sdk: SDKTypes.PingRequest, + spec: SpecTypes.PingRequest +) { + sdk = spec; + spec = sdk; +} +function checkInitializedNotification( + sdk: SDKTypes.InitializedNotification, + spec: SpecTypes.InitializedNotification +) { + sdk = spec; + spec = sdk; +} +function checkListResourcesRequest( + sdk: SDKTypes.ListResourcesRequest, + spec: SpecTypes.ListResourcesRequest +) { + sdk = spec; + spec = sdk; +} +function checkListResourcesResult( + sdk: SDKTypes.ListResourcesResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkListResourceTemplatesRequest( + sdk: SDKTypes.ListResourceTemplatesRequest, + spec: SpecTypes.ListResourceTemplatesRequest +) { + sdk = spec; + spec = sdk; +} +function checkListResourceTemplatesResult( + sdk: SDKTypes.ListResourceTemplatesResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkReadResourceRequest( + sdk: SDKTypes.ReadResourceRequest, + spec: SpecTypes.ReadResourceRequest +) { + sdk = spec; + spec = sdk; +} +function checkReadResourceResult( + sdk: SDKTypes.ReadResourceResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkResourceContents( + sdk: SDKTypes.ResourceContents, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkTextResourceContents( + sdk: SDKTypes.TextResourceContents, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkBlobResourceContents( + sdk: SDKTypes.BlobResourceContents, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkResource( + sdk: SDKTypes.Resource, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkResourceTemplate( + sdk: SDKTypes.ResourceTemplate, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkPromptArgument( + sdk: SDKTypes.PromptArgument, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkPrompt( + sdk: SDKTypes.Prompt, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkListPromptsRequest( + sdk: SDKTypes.ListPromptsRequest, + spec: SpecTypes.ListPromptsRequest +) { + sdk = spec; + spec = sdk; +} +function checkListPromptsResult( + sdk: SDKTypes.ListPromptsResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkGetPromptRequest( + sdk: SDKTypes.GetPromptRequest, + spec: SpecTypes.GetPromptRequest +) { + sdk = spec; + spec = sdk; +} +function checkTextContent( + sdk: SDKTypes.TextContent, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkImageContent( + sdk: SDKTypes.ImageContent, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkAudioContent( + sdk: SDKTypes.AudioContent, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkEmbeddedResource( + sdk: SDKTypes.EmbeddedResource, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkResourceLink( + sdk: SDKTypes.ResourceLink, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkContentBlock( + sdk: SDKTypes.ContentBlock, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkPromptMessage( + sdk: SDKTypes.PromptMessage, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkGetPromptResult( + sdk: SDKTypes.GetPromptResult, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkBooleanSchema( + sdk: SDKTypes.BooleanSchema, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkStringSchema( + sdk: SDKTypes.StringSchema, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkNumberSchema( + sdk: SDKTypes.NumberSchema, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkEnumSchema( + sdk: SDKTypes.EnumSchema, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkPrimitiveSchemaDefinition( + sdk: SDKTypes.PrimitiveSchemaDefinition, + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkCreateMessageRequest( + sdk: DeepKnownKeys, // TODO(quirk): some {} type + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkInitializeRequest( + sdk: DeepKnownKeys, // TODO(quirk): some {} type + spec: SpecTypes.InitializeRequest +) { + sdk = spec; + spec = sdk; +} +function checkInitializeResult( + sdk: DeepKnownKeys, // TODO(quirk): some {} type + spec: SpecTypes.InitializeResult +) { + sdk = spec; + spec = sdk; +} +function checkClientCapabilities( + sdk: DeepKnownKeys, // TODO(quirk): {} + spec: SpecTypes.ClientCapabilities +) { + sdk = spec; + spec = sdk; +} +function checkServerCapabilities( + sdk: DeepKnownKeys, // TODO(quirk): {} + spec: SpecTypes.ServerCapabilities +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCError( + sdk: DeepKnownKeys, // TODO(quirk): error.data + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCMessage( + sdk: DeepKnownKeys, // TODO(quirk): error.data + spec: DeepKnownKeys +) { + sdk = spec; + spec = sdk; +} +function checkClientRequest( + sdk: DeepKnownKeys, // TODO(quirk): capabilities.logging is {} + spec: SpecTypes.ClientRequest +) { + sdk = spec; + spec = sdk; +} +function checkServerRequest( + sdk: DeepKnownKeys, // TODO(quirk): some {} typ + spec: SpecTypes.ServerRequest +) { + sdk = spec; + spec = sdk; +} +function checkLoggingMessageNotification( + sdk: SDKTypes.LoggingMessageNotification, + spec: SpecTypes.LoggingMessageNotification +) { + sdk = spec; + // spec = sdk; // TODO(bug): data is optional +} +function checkServerNotification( + sdk: SDKTypes.ServerNotification, + spec: SpecTypes.ServerNotification +) { + sdk = spec; + // spec = sdk; // TODO(bug): data is optional +} + +// TODO(bug): missing type in SDK +// function checkModelHint( +// sdk: SDKTypes.ModelHint, +// spec: DeepKnownKeys +// ) { +// sdk = spec; +// spec = sdk; +// } + +// TODO(bug): missing type in SDK +// function checkModelPreferences( +// sdk: SDKTypes.ModelPreferences, +// spec: DeepKnownKeys +// ) { +// sdk = spec; +// spec = sdk; +// } + +// TODO(bug): missing type in SDK +// function checkAnnotations( +// sdk: SDKTypes.Annotations, +// spec: DeepKnownKeys +// ) { +// sdk = spec; +// spec = sdk; +// } + +const SPEC_TYPES_FILE = 'src/spec.types.ts'; +const THIS_SOURCE_FILE = 'src/spec.types.test.ts'; + +describe('Spec Types', () => { + const specTypesContent = require('fs').readFileSync(SPEC_TYPES_FILE, 'utf-8'); + const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); + const testContent = require('fs').readFileSync(THIS_SOURCE_FILE, 'utf-8'); + + it('should define some expected types', () => { + expect(typeNames).toContain('JSONRPCNotification'); + expect(typeNames).toContain('ElicitResult'); + }); + + for (const typeName of typeNames) { + it(`${typeName} should have a compatibility test`, () => { + expect(testContent).toContain(`function check${typeName}(`); + }); + } +}); \ No newline at end of file From d1a30ab2d2a41bcc10b67f401c1ffaaf0c3ffaaf Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 19:29:30 +0100 Subject: [PATCH 119/208] disable lints in spec.types.test.ts --- src/spec.types.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index b6c886896..bdfd953bd 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -1,9 +1,13 @@ -// import ts from 'typescript'; - import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +/* eslint-disable @typescript-eslint/no-require-imports */ + // Deep version that recursively removes index signatures (caused by ZodObject.passthrough()) and turns unknowns into `object | undefined` +// TODO: make string index mapping tighter +// TODO: split into multiple transformations (e.g. RemovePassthrough) and only use the ones needed for each type. type DeepKnownKeys = T extends object ? T extends Array ? Array> From e0fc39066d3d9b2173e59120df4afe32c776faac Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 19:37:50 +0100 Subject: [PATCH 120/208] rename + switch position of RemovePassthrough --- src/spec.types.test.ts | 242 ++++++++++++++++++++++------------------- 1 file changed, 130 insertions(+), 112 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index bdfd953bd..515079bfb 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -7,14 +7,14 @@ import * as SpecTypes from "./spec.types.js"; // Deep version that recursively removes index signatures (caused by ZodObject.passthrough()) and turns unknowns into `object | undefined` // TODO: make string index mapping tighter -// TODO: split into multiple transformations (e.g. RemovePassthrough) and only use the ones needed for each type. -type DeepKnownKeys = T extends object +// TODO: split into multiple transformations if needed +type RemovePassthrough = T extends object ? T extends Array - ? Array> + ? Array> : T extends Function ? T : { - [K in keyof T as string extends K ? never : number extends K ? never : K]: DeepKnownKeys; + [K in keyof T as string extends K ? never : number extends K ? never : K]: RemovePassthrough; } : unknown extends T ? (object | undefined) @@ -28,22 +28,22 @@ function checkCancelledNotification( spec = sdk; } function checkBaseMetadata( - sdk: SDKTypes.BaseMetadata, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.BaseMetadata ) { sdk = spec; spec = sdk; } function checkImplementation( - sdk: SDKTypes.Implementation, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.Implementation ) { sdk = spec; spec = sdk; } function checkProgressNotification( - sdk: SDKTypes.ProgressNotification, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ProgressNotification ) { sdk = spec; spec = sdk; @@ -85,29 +85,44 @@ function checkListRootsRequest( spec = sdk; } function checkListRootsResult( - sdk: SDKTypes.ListRootsResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ListRootsResult ) { sdk = spec; spec = sdk; } -function checkRoot(sdk: SDKTypes.Root, spec: DeepKnownKeys) { +function checkRoot( + sdk: RemovePassthrough, + spec: SpecTypes.Root +) { sdk = spec; spec = sdk; } -function checkElicitRequest(sdk: SDKTypes.ElicitRequest, spec: DeepKnownKeys) { +function checkElicitRequest( + sdk: RemovePassthrough, + spec: SpecTypes.ElicitRequest +) { sdk = spec; spec = sdk; } -function checkElicitResult(sdk: SDKTypes.ElicitResult, spec: DeepKnownKeys) { +function checkElicitResult( + sdk: RemovePassthrough, + spec: SpecTypes.ElicitResult +) { sdk = spec; spec = sdk; } -function checkCompleteRequest(sdk: SDKTypes.CompleteRequest, spec: DeepKnownKeys) { +function checkCompleteRequest( + sdk: RemovePassthrough, + spec: SpecTypes.CompleteRequest +) { sdk = spec; spec = sdk; } -function checkCompleteResult(sdk: SDKTypes.CompleteResult, spec: SpecTypes.CompleteResult) { +function checkCompleteResult( + sdk: SDKTypes.CompleteResult, + spec: SpecTypes.CompleteResult +) { sdk = spec; spec = sdk; } @@ -118,7 +133,10 @@ function checkProgressToken( sdk = spec; spec = sdk; } -function checkCursor(sdk: SDKTypes.Cursor, spec: SpecTypes.Cursor) { +function checkCursor( + sdk: SDKTypes.Cursor, + spec: SpecTypes.Cursor +) { sdk = spec; spec = sdk; } @@ -200,36 +218,36 @@ function checkServerResult( spec = sdk; } function checkResourceTemplateReference( - sdk: SDKTypes.ResourceTemplateReference, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ResourceTemplateReference ) { sdk = spec; spec = sdk; } function checkPromptReference( - sdk: SDKTypes.PromptReference, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.PromptReference ) { sdk = spec; spec = sdk; } function checkResourceReference( - sdk: SDKTypes.ResourceReference, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ResourceTemplateReference ) { sdk = spec; spec = sdk; } function checkToolAnnotations( - sdk: SDKTypes.ToolAnnotations, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ToolAnnotations ) { sdk = spec; spec = sdk; } function checkTool( - sdk: SDKTypes.Tool, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.Tool ) { sdk = spec; spec = sdk; @@ -242,15 +260,15 @@ function checkListToolsRequest( spec = sdk; } function checkListToolsResult( - sdk: SDKTypes.ListToolsResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ListToolsResult ) { sdk = spec; spec = sdk; } function checkCallToolResult( - sdk: SDKTypes.CallToolResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.CallToolResult ) { sdk = spec; spec = sdk; @@ -298,15 +316,15 @@ function checkResourceUpdatedNotification( spec = sdk; } function checkSamplingMessage( - sdk: SDKTypes.SamplingMessage, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.SamplingMessage ) { sdk = spec; spec = sdk; } function checkCreateMessageResult( - sdk: SDKTypes.CreateMessageResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.CreateMessageResult ) { sdk = spec; spec = sdk; @@ -340,8 +358,8 @@ function checkListResourcesRequest( spec = sdk; } function checkListResourcesResult( - sdk: SDKTypes.ListResourcesResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ListResourcesResult ) { sdk = spec; spec = sdk; @@ -354,8 +372,8 @@ function checkListResourceTemplatesRequest( spec = sdk; } function checkListResourceTemplatesResult( - sdk: SDKTypes.ListResourceTemplatesResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ListResourceTemplatesResult ) { sdk = spec; spec = sdk; @@ -368,57 +386,57 @@ function checkReadResourceRequest( spec = sdk; } function checkReadResourceResult( - sdk: SDKTypes.ReadResourceResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ReadResourceResult ) { sdk = spec; spec = sdk; } function checkResourceContents( - sdk: SDKTypes.ResourceContents, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ResourceContents ) { sdk = spec; spec = sdk; } function checkTextResourceContents( - sdk: SDKTypes.TextResourceContents, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.TextResourceContents ) { sdk = spec; spec = sdk; } function checkBlobResourceContents( - sdk: SDKTypes.BlobResourceContents, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.BlobResourceContents ) { sdk = spec; spec = sdk; } function checkResource( - sdk: SDKTypes.Resource, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.Resource ) { sdk = spec; spec = sdk; } function checkResourceTemplate( - sdk: SDKTypes.ResourceTemplate, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ResourceTemplate ) { sdk = spec; spec = sdk; } function checkPromptArgument( - sdk: SDKTypes.PromptArgument, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.PromptArgument ) { sdk = spec; spec = sdk; } function checkPrompt( - sdk: SDKTypes.Prompt, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.Prompt ) { sdk = spec; spec = sdk; @@ -431,8 +449,8 @@ function checkListPromptsRequest( spec = sdk; } function checkListPromptsResult( - sdk: SDKTypes.ListPromptsResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ListPromptsResult ) { sdk = spec; spec = sdk; @@ -445,154 +463,154 @@ function checkGetPromptRequest( spec = sdk; } function checkTextContent( - sdk: SDKTypes.TextContent, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.TextContent ) { sdk = spec; spec = sdk; } function checkImageContent( - sdk: SDKTypes.ImageContent, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ImageContent ) { sdk = spec; spec = sdk; } function checkAudioContent( - sdk: SDKTypes.AudioContent, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.AudioContent ) { sdk = spec; spec = sdk; } function checkEmbeddedResource( - sdk: SDKTypes.EmbeddedResource, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.EmbeddedResource ) { sdk = spec; spec = sdk; } function checkResourceLink( - sdk: SDKTypes.ResourceLink, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ResourceLink ) { sdk = spec; spec = sdk; } function checkContentBlock( - sdk: SDKTypes.ContentBlock, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.ContentBlock ) { sdk = spec; spec = sdk; } function checkPromptMessage( - sdk: SDKTypes.PromptMessage, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.PromptMessage ) { sdk = spec; spec = sdk; } function checkGetPromptResult( - sdk: SDKTypes.GetPromptResult, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.GetPromptResult ) { sdk = spec; spec = sdk; } function checkBooleanSchema( - sdk: SDKTypes.BooleanSchema, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.BooleanSchema ) { sdk = spec; spec = sdk; } function checkStringSchema( - sdk: SDKTypes.StringSchema, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.StringSchema ) { sdk = spec; spec = sdk; } function checkNumberSchema( - sdk: SDKTypes.NumberSchema, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.NumberSchema ) { sdk = spec; spec = sdk; } function checkEnumSchema( - sdk: SDKTypes.EnumSchema, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.EnumSchema ) { sdk = spec; spec = sdk; } function checkPrimitiveSchemaDefinition( - sdk: SDKTypes.PrimitiveSchemaDefinition, - spec: DeepKnownKeys + sdk: RemovePassthrough, + spec: SpecTypes.PrimitiveSchemaDefinition +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCError( + sdk: SDKTypes.JSONRPCError, + spec: SpecTypes.JSONRPCError +) { + sdk = spec; + spec = sdk; +} +function checkJSONRPCMessage( + sdk: SDKTypes.JSONRPCMessage, + spec: SpecTypes.JSONRPCMessage ) { sdk = spec; spec = sdk; } function checkCreateMessageRequest( - sdk: DeepKnownKeys, // TODO(quirk): some {} type - spec: DeepKnownKeys + sdk: RemovePassthrough, // TODO(quirk): some {} typ>e + spec: SpecTypes.CreateMessageRequest ) { sdk = spec; spec = sdk; } function checkInitializeRequest( - sdk: DeepKnownKeys, // TODO(quirk): some {} type + sdk: RemovePassthrough, // TODO(quirk): some {} type spec: SpecTypes.InitializeRequest ) { sdk = spec; spec = sdk; } function checkInitializeResult( - sdk: DeepKnownKeys, // TODO(quirk): some {} type + sdk: RemovePassthrough, // TODO(quirk): some {} type spec: SpecTypes.InitializeResult ) { sdk = spec; spec = sdk; } function checkClientCapabilities( - sdk: DeepKnownKeys, // TODO(quirk): {} + sdk: RemovePassthrough, // TODO(quirk): {} spec: SpecTypes.ClientCapabilities ) { sdk = spec; spec = sdk; } function checkServerCapabilities( - sdk: DeepKnownKeys, // TODO(quirk): {} + sdk: RemovePassthrough, // TODO(quirk): {} spec: SpecTypes.ServerCapabilities ) { sdk = spec; spec = sdk; } -function checkJSONRPCError( - sdk: DeepKnownKeys, // TODO(quirk): error.data - spec: DeepKnownKeys -) { - sdk = spec; - spec = sdk; -} -function checkJSONRPCMessage( - sdk: DeepKnownKeys, // TODO(quirk): error.data - spec: DeepKnownKeys -) { - sdk = spec; - spec = sdk; -} function checkClientRequest( - sdk: DeepKnownKeys, // TODO(quirk): capabilities.logging is {} + sdk: RemovePassthrough, // TODO(quirk): capabilities.logging is {} spec: SpecTypes.ClientRequest ) { sdk = spec; spec = sdk; } function checkServerRequest( - sdk: DeepKnownKeys, // TODO(quirk): some {} typ + sdk: RemovePassthrough, // TODO(quirk): some {} typ spec: SpecTypes.ServerRequest ) { sdk = spec; @@ -615,8 +633,8 @@ function checkServerNotification( // TODO(bug): missing type in SDK // function checkModelHint( -// sdk: SDKTypes.ModelHint, -// spec: DeepKnownKeys +// RemovePassthrough< sdk: SDKTypes.ModelHint>, +// spec: SpecTypes.ModelHint // ) { // sdk = spec; // spec = sdk; @@ -624,8 +642,8 @@ function checkServerNotification( // TODO(bug): missing type in SDK // function checkModelPreferences( -// sdk: SDKTypes.ModelPreferences, -// spec: DeepKnownKeys +// RemovePassthrough< sdk: SDKTypes.ModelPreferences>, +// spec: SpecTypes.ModelPreferences // ) { // sdk = spec; // spec = sdk; @@ -633,8 +651,8 @@ function checkServerNotification( // TODO(bug): missing type in SDK // function checkAnnotations( -// sdk: SDKTypes.Annotations, -// spec: DeepKnownKeys +// RemovePassthrough< sdk: SDKTypes.Annotations>, +// spec: SpecTypes.Annotations // ) { // sdk = spec; // spec = sdk; From b3c5d27c701f98f468576f70cfa155f09fe69ef3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 19:38:58 +0100 Subject: [PATCH 121/208] Update spec.types.test.ts --- src/spec.types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 515079bfb..a36aec1af 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -42,7 +42,7 @@ function checkImplementation( spec = sdk; } function checkProgressNotification( - sdk: RemovePassthrough, + sdk: SDKTypes.ProgressNotification, spec: SpecTypes.ProgressNotification ) { sdk = spec; From d57d35e3480746f9cf872e9754a21d03abe9c19b Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 19:41:51 +0100 Subject: [PATCH 122/208] Update spec.types.test.ts --- src/spec.types.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index a36aec1af..b8b89fc69 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -12,12 +12,8 @@ type RemovePassthrough = T extends object ? T extends Array ? Array> : T extends Function - ? T - : { - [K in keyof T as string extends K ? never : number extends K ? never : K]: RemovePassthrough; - } - : unknown extends T - ? (object | undefined) + ? T + : {[K in keyof T as string extends K ? never : K]: RemovePassthrough} : T; function checkCancelledNotification( From e3888069f9bbb53d29ac85d3592096b4cf4be532 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 20:40:43 +0100 Subject: [PATCH 123/208] add MakeUnknownsNotOptional to fix optionality of Zod unknown() fields --- package.json | 2 +- src/spec.types.test.ts | 71 ++++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index bbca197b4..79da51939 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dist" ], "scripts": { - "fetch:spec-types": "test -f src/spec.types.ts || curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", + "fetch:spec-types": "curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", "build": "npm run build:esm && npm run build:cjs", "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", "build:esm:w": "npm run build:esm -- -w", diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index b8b89fc69..8914fcbae 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -1,3 +1,10 @@ +/** + * This contains: + * - Static type checks to verify the Spec's types are compatible with the SDK's types + * (mutually assignable, w/ slight affordances to get rid of ZodObject.passthrough() index signatures, etc) + * - Runtime checks to verify all Spec types have a static check + * (a few don't have SDK types, see TODOs in this file) + */ import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; @@ -5,16 +12,40 @@ import * as SpecTypes from "./spec.types.js"; /* eslint-disable @typescript-eslint/no-unsafe-function-type */ /* eslint-disable @typescript-eslint/no-require-imports */ -// Deep version that recursively removes index signatures (caused by ZodObject.passthrough()) and turns unknowns into `object | undefined` -// TODO: make string index mapping tighter -// TODO: split into multiple transformations if needed +// Removes index signatures added by ZodObject.passthrough(). type RemovePassthrough = T extends object ? T extends Array ? Array> : T extends Function ? T : {[K in keyof T as string extends K ? never : K]: RemovePassthrough} - : T; + : T; + +type IsUnknown = [unknown] extends [T] ? [T] extends [unknown] ? true : false : false; + +// Turns {x?: unknown} into {x: unknown} but keeps {_meta?: unknown} unchanged (and leaves other optional properties unchanged, e.g. {x?: string}). +// This works around an apparent quirk of ZodObject.unknown() (makes fields optional) +type MakeUnknownsNotOptional = + IsUnknown extends true + ? unknown + : (T extends object + ? (T extends Array + ? Array> + : (T extends Function + ? T + : Pick & { + // Start with empty object to avoid duplicates + // Make unknown properties required (except _meta) + [K in keyof T as '_meta' extends K ? never : IsUnknown extends true ? K : never]-?: unknown; + } & + Pick extends true ? never : K + }[keyof T]> & { + // Recurse on the picked properties + [K in keyof Pick extends true ? never : K}[keyof T]>]: MakeUnknownsNotOptional + })) + : T); function checkCancelledNotification( sdk: SDKTypes.CancelledNotification, @@ -36,7 +67,7 @@ function checkImplementation( ) { sdk = spec; spec = sdk; -} +} function checkProgressNotification( sdk: SDKTypes.ProgressNotification, spec: SpecTypes.ProgressNotification @@ -564,70 +595,70 @@ function checkJSONRPCMessage( spec = sdk; } function checkCreateMessageRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} typ>e + sdk: RemovePassthrough, spec: SpecTypes.CreateMessageRequest ) { sdk = spec; spec = sdk; } function checkInitializeRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} type + sdk: RemovePassthrough, spec: SpecTypes.InitializeRequest ) { sdk = spec; spec = sdk; } function checkInitializeResult( - sdk: RemovePassthrough, // TODO(quirk): some {} type + sdk: RemovePassthrough, spec: SpecTypes.InitializeResult ) { sdk = spec; spec = sdk; } function checkClientCapabilities( - sdk: RemovePassthrough, // TODO(quirk): {} + sdk: RemovePassthrough, spec: SpecTypes.ClientCapabilities ) { sdk = spec; spec = sdk; } function checkServerCapabilities( - sdk: RemovePassthrough, // TODO(quirk): {} + sdk: RemovePassthrough, spec: SpecTypes.ServerCapabilities ) { sdk = spec; spec = sdk; } function checkClientRequest( - sdk: RemovePassthrough, // TODO(quirk): capabilities.logging is {} + sdk: RemovePassthrough, spec: SpecTypes.ClientRequest ) { sdk = spec; spec = sdk; } function checkServerRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} typ + sdk: RemovePassthrough, spec: SpecTypes.ServerRequest ) { sdk = spec; spec = sdk; } function checkLoggingMessageNotification( - sdk: SDKTypes.LoggingMessageNotification, + sdk: MakeUnknownsNotOptional, spec: SpecTypes.LoggingMessageNotification ) { sdk = spec; - // spec = sdk; // TODO(bug): data is optional + spec = sdk; } function checkServerNotification( - sdk: SDKTypes.ServerNotification, + sdk: MakeUnknownsNotOptional, spec: SpecTypes.ServerNotification ) { sdk = spec; - // spec = sdk; // TODO(bug): data is optional + spec = sdk; } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkModelHint( // RemovePassthrough< sdk: SDKTypes.ModelHint>, // spec: SpecTypes.ModelHint @@ -636,7 +667,7 @@ function checkServerNotification( // spec = sdk; // } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkModelPreferences( // RemovePassthrough< sdk: SDKTypes.ModelPreferences>, // spec: SpecTypes.ModelPreferences @@ -645,7 +676,7 @@ function checkServerNotification( // spec = sdk; // } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkAnnotations( // RemovePassthrough< sdk: SDKTypes.Annotations>, // spec: SpecTypes.Annotations @@ -661,7 +692,7 @@ describe('Spec Types', () => { const specTypesContent = require('fs').readFileSync(SPEC_TYPES_FILE, 'utf-8'); const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); const testContent = require('fs').readFileSync(THIS_SOURCE_FILE, 'utf-8'); - + it('should define some expected types', () => { expect(typeNames).toContain('JSONRPCNotification'); expect(typeNames).toContain('ElicitResult'); From 0cd88db1f7577d0bc6a1dec8cdbcf91f37b9264a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 20:43:45 +0100 Subject: [PATCH 124/208] Update spec.types.test.ts --- src/spec.types.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 8914fcbae..7ae1f2b84 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -2,8 +2,8 @@ * This contains: * - Static type checks to verify the Spec's types are compatible with the SDK's types * (mutually assignable, w/ slight affordances to get rid of ZodObject.passthrough() index signatures, etc) - * - Runtime checks to verify all Spec types have a static check - * (a few don't have SDK types, see TODOs in this file) + * - Runtime checks to verify each Spec type has a static check + * (note: a few don't have SDK types, see TODOs in this file) */ import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; @@ -685,6 +685,7 @@ function checkServerNotification( // spec = sdk; // } +// This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) const SPEC_TYPES_FILE = 'src/spec.types.ts'; const THIS_SOURCE_FILE = 'src/spec.types.test.ts'; From a19175a26e7f929239766522ef27b2c48e9334b5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 20:44:58 +0100 Subject: [PATCH 125/208] Update spec.types.test.ts --- src/spec.types.test.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 7ae1f2b84..be8891ca1 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -7,6 +7,7 @@ */ import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; +import fs from "node:fs"; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ @@ -690,18 +691,18 @@ const SPEC_TYPES_FILE = 'src/spec.types.ts'; const THIS_SOURCE_FILE = 'src/spec.types.test.ts'; describe('Spec Types', () => { - const specTypesContent = require('fs').readFileSync(SPEC_TYPES_FILE, 'utf-8'); - const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); - const testContent = require('fs').readFileSync(THIS_SOURCE_FILE, 'utf-8'); + const specTypesContent = fs.readFileSync(SPEC_TYPES_FILE, 'utf-8'); + const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); + const testContent = fs.readFileSync(THIS_SOURCE_FILE, 'utf-8'); - it('should define some expected types', () => { - expect(typeNames).toContain('JSONRPCNotification'); - expect(typeNames).toContain('ElicitResult'); - }); + it('should define some expected types', () => { + expect(typeNames).toContain('JSONRPCNotification'); + expect(typeNames).toContain('ElicitResult'); + }); - for (const typeName of typeNames) { - it(`${typeName} should have a compatibility test`, () => { - expect(testContent).toContain(`function check${typeName}(`); - }); - } + for (const typeName of typeNames) { + it(`${typeName} should have a compatibility test`, () => { + expect(testContent).toContain(`function check${typeName}(`); + }); + } }); \ No newline at end of file From 42d4abf383a7eb710d5e12322c5ae5d09ea4a817 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 3 Jul 2025 18:06:29 -0400 Subject: [PATCH 126/208] bump version to 1.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 15b7753be..e381dd12b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.14.0", + "version": "1.15.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 979700a7b76746e5aeb92e1b20e5968f30a8286d Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 3 Jul 2025 18:13:48 -0400 Subject: [PATCH 127/208] npm install --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2c8cbfa5..01bc09539 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.14.0", + "version": "1.15.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", From abcf91b4634c21befca147bc4790956c838fbd22 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:10:07 +0800 Subject: [PATCH 128/208] Fix oauth-protected-resource to also be path aware --- src/client/auth.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a428..9aa5dda5a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -261,7 +261,9 @@ export async function discoverOAuthProtectedResourceMetadata( if (opts?.resourceMetadataUrl) { url = new URL(opts?.resourceMetadataUrl); } else { - url = new URL("/.well-known/oauth-protected-resource", serverUrl); + const issuer = new URL(serverUrl); + const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); + url = new URL(wellKnownPath, issuer); } let response: Response; @@ -318,8 +320,8 @@ async function fetchWithCorsRetry( /** * Constructs the well-known path for OAuth metadata discovery */ -function buildWellKnownPath(pathname: string): string { - let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; +function buildWellKnownPath(wellKnownPath: string, pathname: string): string { + let wellKnownPath = `/.well-known/${wellKnownPath}${pathname}`; if (pathname.endsWith('/')) { // Strip trailing slash from pathname to avoid double slashes wellKnownPath = wellKnownPath.slice(0, -1); @@ -361,7 +363,7 @@ export async function discoverOAuthMetadata( const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; // Try path-aware discovery first (RFC 8414 compliant) - const wellKnownPath = buildWellKnownPath(issuer.pathname); + const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); const pathAwareUrl = new URL(wellKnownPath, issuer); let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); From c5fcba4617222cd1d1e58ecd318b5cf7346397e7 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:14:14 +0800 Subject: [PATCH 129/208] Update auth.ts --- src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 9aa5dda5a..8839ecf0a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -320,8 +320,8 @@ async function fetchWithCorsRetry( /** * Constructs the well-known path for OAuth metadata discovery */ -function buildWellKnownPath(wellKnownPath: string, pathname: string): string { - let wellKnownPath = `/.well-known/${wellKnownPath}${pathname}`; +function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string { + let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`; if (pathname.endsWith('/')) { // Strip trailing slash from pathname to avoid double slashes wellKnownPath = wellKnownPath.slice(0, -1); From 2fc1581dfb0dbf99d6ad7ce0aae3f70ee302dc12 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 4 Jul 2025 10:28:25 +0100 Subject: [PATCH 130/208] widen type extraction regex, introduce + test MISSING_SDK_TYPES, check LoggingLevel --- src/spec.types.test.ts | 74 ++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index be8891ca1..105ab9065 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -658,51 +658,55 @@ function checkServerNotification( sdk = spec; spec = sdk; } +function checkLoggingLevel( + sdk: SDKTypes.LoggingLevel, + spec: SpecTypes.LoggingLevel +) { + sdk = spec; + spec = sdk; +} -// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. -// function checkModelHint( -// RemovePassthrough< sdk: SDKTypes.ModelHint>, -// spec: SpecTypes.ModelHint -// ) { -// sdk = spec; -// spec = sdk; -// } +// This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) +const SPEC_TYPES_FILE = 'src/spec.types.ts'; +const SDK_TYPES_FILE = 'src/types.ts'; -// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. -// function checkModelPreferences( -// RemovePassthrough< sdk: SDKTypes.ModelPreferences>, -// spec: SpecTypes.ModelPreferences -// ) { -// sdk = spec; -// spec = sdk; -// } +const MISSING_SDK_TYPES = [ + // These are inlined in the SDK: + 'Role', -// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. -// function checkAnnotations( -// RemovePassthrough< sdk: SDKTypes.Annotations>, -// spec: SpecTypes.Annotations -// ) { -// sdk = spec; -// spec = sdk; -// } + // These aren't supported by the SDK yet: + // TODO: Add definitions to the SDK + 'ModelHint', + 'ModelPreferences', + 'Annotations', +] -// This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) -const SPEC_TYPES_FILE = 'src/spec.types.ts'; -const THIS_SOURCE_FILE = 'src/spec.types.test.ts'; +function extractExportedTypes(source: string): string[] { + return [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)].map(m => m[1]); +} describe('Spec Types', () => { - const specTypesContent = fs.readFileSync(SPEC_TYPES_FILE, 'utf-8'); - const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); - const testContent = fs.readFileSync(THIS_SOURCE_FILE, 'utf-8'); + const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf-8')); + const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf-8')); + const testSource = fs.readFileSync(__filename, 'utf-8'); it('should define some expected types', () => { - expect(typeNames).toContain('JSONRPCNotification'); - expect(typeNames).toContain('ElicitResult'); + expect(specTypes).toContain('JSONRPCNotification'); + expect(specTypes).toContain('ElicitResult'); + }); + + it('should have up to date list of missing sdk types', () => { + for (const typeName of MISSING_SDK_TYPES) { + expect(sdkTypes).not.toContain(typeName); + } }); - for (const typeName of typeNames) { - it(`${typeName} should have a compatibility test`, () => { - expect(testContent).toContain(`function check${typeName}(`); + for (const type of specTypes) { + if (MISSING_SDK_TYPES.includes(type)) { + continue; // Skip missing SDK types + } + it(`${type} should have a compatibility test`, () => { + expect(testSource).toContain(`function check${type}(`); }); } }); \ No newline at end of file From 3ae5522193c88ffa3fe265da8c68260da321d9d5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 4 Jul 2025 10:30:36 +0100 Subject: [PATCH 131/208] Update spec.types.test.ts --- src/spec.types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 105ab9065..ffeffeed1 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -3,7 +3,7 @@ * - Static type checks to verify the Spec's types are compatible with the SDK's types * (mutually assignable, w/ slight affordances to get rid of ZodObject.passthrough() index signatures, etc) * - Runtime checks to verify each Spec type has a static check - * (note: a few don't have SDK types, see TODOs in this file) + * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) */ import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; From b6e00f9a492d84a9b6915e998b6309e61c9aa0cd Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 4 Jul 2025 10:33:14 +0100 Subject: [PATCH 132/208] Update spec.types.test.ts --- src/spec.types.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index ffeffeed1..eefc80b5c 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -676,9 +676,9 @@ const MISSING_SDK_TYPES = [ // These aren't supported by the SDK yet: // TODO: Add definitions to the SDK + 'Annotations', 'ModelHint', 'ModelPreferences', - 'Annotations', ] function extractExportedTypes(source: string): string[] { @@ -693,6 +693,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); + expect(specTypes).toHaveLength(91); }); it('should have up to date list of missing sdk types', () => { @@ -709,4 +710,4 @@ describe('Spec Types', () => { expect(testSource).toContain(`function check${type}(`); }); } -}); \ No newline at end of file +}); From a7e4289f3eccfdc3f62a221073228da5c0150bf4 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 4 Jul 2025 10:34:50 +0100 Subject: [PATCH 133/208] Update spec.types.test.ts --- src/spec.types.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index eefc80b5c..87514cb05 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -11,7 +11,6 @@ import fs from "node:fs"; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ -/* eslint-disable @typescript-eslint/no-require-imports */ // Removes index signatures added by ZodObject.passthrough(). type RemovePassthrough = T extends object From bda811a95037b71dae55fdc2f2fc55660aaf486b Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 30 Jun 2025 15:21:57 +0100 Subject: [PATCH 134/208] feat: Add CORS configuration for browser-based MCP clients - Add cors middleware to example servers with Mcp-Session-Id exposed - Add CORS documentation section to README - Configure minimal CORS settings (only expose required headers) This enables browser-based clients to connect to MCP servers by properly exposing the Mcp-Session-Id header required for session management. Reported-by: Jerome --- README.md | 20 +++++++++++++++++++ .../server/jsonResponseStreamableHttp.ts | 7 +++++++ .../server/simpleStatelessStreamableHttp.ts | 7 +++++++ .../sseAndStreamableHttpCompatibleServer.ts | 7 +++++++ 4 files changed, 41 insertions(+) diff --git a/README.md b/README.md index ac10e8cb0..651893531 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,26 @@ app.listen(3000); > ); > ``` + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```typescript +import cors from 'cors'; + +// Add CORS middleware before your MCP routes +app.use(cors({ + origin: '*', // Configure appropriately for production + exposedHeaders: ['Mcp-Session-Id'] +})); +``` + +This configuration is necessary because: +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + #### Without Session Management (Stateless) For simpler use cases where session management isn't needed: diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 02d8c2de0..04b14470b 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -4,6 +4,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, isInitializeRequest } from '../../types.js'; +import cors from 'cors'; // Create an MCP server with implementation details @@ -81,6 +82,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 6fb2ae831..d235265cd 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -3,6 +3,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import cors from 'cors'; const getServer = () => { // Create an MCP server with implementation details @@ -96,6 +97,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); try { diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index ded110a13..7b18578a5 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -6,6 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js'; import { z } from 'zod'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import cors from 'cors'; /** * This example server demonstrates backwards compatibility with both: @@ -71,6 +72,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + // Store transports by session ID const transports: Record = {}; From 7b02c5cda377c7cd9ebcf827ab368bf96e0534cf Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 7 Jul 2025 03:56:42 +0800 Subject: [PATCH 135/208] Retain URL search parameter --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 8839ecf0a..495f62a4d 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -263,6 +263,7 @@ export async function discoverOAuthProtectedResourceMetadata( } else { const issuer = new URL(serverUrl); const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); + wellKnownPath.search = issuer.search; url = new URL(wellKnownPath, issuer); } @@ -365,6 +366,7 @@ export async function discoverOAuthMetadata( // Try path-aware discovery first (RFC 8414 compliant) const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); const pathAwareUrl = new URL(wellKnownPath, issuer); + pathAwareUrl.search = issuer.search; let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); // If path-aware discovery fails with 404, try fallback to root discovery From 3bdecfc1b9618cbc9e8f9635a14d46ea0d3925d6 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 7 Jul 2025 03:59:06 +0800 Subject: [PATCH 136/208] Update auth.ts --- src/client/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 495f62a4d..eb3473ada 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -263,8 +263,8 @@ export async function discoverOAuthProtectedResourceMetadata( } else { const issuer = new URL(serverUrl); const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); - wellKnownPath.search = issuer.search; url = new URL(wellKnownPath, issuer); + url.search = issuer.search; } let response: Response; From d4bfe556bf1f64e336b7b2f36f2c40da42bd8a48 Mon Sep 17 00:00:00 2001 From: sunrabbit123 Date: Mon, 7 Jul 2025 18:50:24 +0900 Subject: [PATCH 137/208] fix: format error with merge Signed-off-by: sunrabbit123 --- src/client/stdio.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 0e92eac13..3d76a8196 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -130,12 +130,12 @@ test("should override default environment variables with custom ones", async () } }); -test("should return child process pid", async () => { - const client = new StdioClientTransport(serverParameters); - - await client.start(); - expect(client.pid).not.toBeNull(); - await client.close(); - expect(client.pid).toBeNull(); + test("should return child process pid", async () => { + const client = new StdioClientTransport(serverParameters); + await client.start(); + expect(client.pid).not.toBeNull(); + await client.close(); + expect(client.pid).toBeNull(); + }); }); From 0ff7ae09f9c76a1edf8c1ad5e1d23b739e631e91 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Jul 2025 11:22:02 +0100 Subject: [PATCH 138/208] simplify tests --- src/client/cross-spawn.test.ts | 24 +++++++++-- src/client/stdio.test.ts | 79 ++-------------------------------- 2 files changed, 25 insertions(+), 78 deletions(-) diff --git a/src/client/cross-spawn.test.ts b/src/client/cross-spawn.test.ts index 724ec7066..8480d94f7 100644 --- a/src/client/cross-spawn.test.ts +++ b/src/client/cross-spawn.test.ts @@ -1,4 +1,4 @@ -import { getDefaultEnvironment, StdioClientTransport } from "./stdio.js"; +import { StdioClientTransport, getDefaultEnvironment } from "./stdio.js"; import spawn from "cross-spawn"; import { JSONRPCMessage } from "../types.js"; import { ChildProcess } from "node:child_process"; @@ -67,19 +67,37 @@ describe("StdioClientTransport using cross-spawn", () => { await transport.start(); - // verify environment variables are passed correctly + // verify environment variables are merged correctly expect(mockSpawn).toHaveBeenCalledWith( "test-command", [], expect.objectContaining({ env: { ...getDefaultEnvironment(), - ...customEnv, + ...customEnv } }) ); }); + test("should use default environment when env is undefined", async () => { + const transport = new StdioClientTransport({ + command: "test-command", + env: undefined + }); + + await transport.start(); + + // verify default environment is used + expect(mockSpawn).toHaveBeenCalledWith( + "test-command", + [], + expect.objectContaining({ + env: getDefaultEnvironment() + }) + ); + }); + test("should send messages correctly", async () => { const transport = new StdioClientTransport({ command: "test-command" diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 3d76a8196..b21324469 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,24 +1,10 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, StdioServerParameters, DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from "./stdio.js"; -import { AsyncLocalStorage } from "node:async_hooks"; +import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; const serverParameters: StdioServerParameters = { command: "/usr/bin/tee", }; -const envAsyncLocalStorage = new AsyncLocalStorage<{ env: Record }>(); - -jest.mock('cross-spawn', () => { - const originalSpawn = jest.requireActual('cross-spawn'); - return jest.fn((command, args, options) => { - const env = envAsyncLocalStorage.getStore(); - if (env) { - env.env = options.env; - } - return originalSpawn(command, args, options); - }); -}); - test("should start then close cleanly", async () => { const client = new StdioClientTransport(serverParameters); client.onerror = (error) => { @@ -74,68 +60,11 @@ test("should read messages", async () => { await client.close(); }); - -test("should properly set default environment variables in spawned process", async () => { - await envAsyncLocalStorage.run({ env: {} }, async () => { +test("should return child process pid", async () => { const client = new StdioClientTransport(serverParameters); await client.start(); + expect(client.pid).not.toBeNull(); await client.close(); - - // Get the default environment variables - const defaultEnv = getDefaultEnvironment(); - const spawnEnv = envAsyncLocalStorage.getStore()?.env; - expect(spawnEnv).toBeDefined(); - // Verify that all default environment variables are present - for (const key of DEFAULT_INHERITED_ENV_VARS) { - if (process.env[key] && !process.env[key].startsWith("()")) { - expect(spawnEnv).toHaveProperty(key); - expect(spawnEnv![key]).toBe(process.env[key]); - expect(spawnEnv![key]).toBe(defaultEnv[key]); - } - } - }); -}); - -test("should override default environment variables with custom ones", async () => { - await envAsyncLocalStorage.run({ env: {} }, async () => { - const customEnv = { - HOME: "/custom/home", - PATH: "/custom/path", - USER: "custom_user" - }; - - const client = new StdioClientTransport({ - ...serverParameters, - env: customEnv - }); - - await client.start(); - await client.close(); - - const spawnEnv = envAsyncLocalStorage.getStore()?.env; - expect(spawnEnv).toBeDefined(); - // Verify that custom environment variables override default ones - for (const [key, value] of Object.entries(customEnv)) { - expect(spawnEnv).toHaveProperty(key); - expect(spawnEnv![key]).toBe(value); - } - - // Verify that other default environment variables are still present - for (const key of DEFAULT_INHERITED_ENV_VARS) { - if (!(key in customEnv) && process.env[key] && !process.env[key].startsWith("()")) { - expect(spawnEnv).toHaveProperty(key); - expect(spawnEnv![key]).toBe(process.env[key]); - } - } - }); - - test("should return child process pid", async () => { - const client = new StdioClientTransport(serverParameters); - - await client.start(); - expect(client.pid).not.toBeNull(); - await client.close(); - expect(client.pid).toBeNull(); - }); + expect(client.pid).toBeNull(); }); From 3d381df2bc53ad6fbe5eba80d6454d83d3d5ab13 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 12:35:52 +0100 Subject: [PATCH 139/208] Added cors settings to simpleStreamableHttp example server --- src/examples/server/simpleStreamableHttp.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3d5235430..029fff77a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -11,6 +11,8 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; import { checkResourceAllowed } from 'src/shared/auth-utils.js'; +import cors from 'cors'; + // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); const strictOAuth = process.argv.includes('--oauth-strict'); @@ -420,12 +422,18 @@ const getServer = () => { return server; }; -const MCP_PORT = 3000; -const AUTH_PORT = 3001; +const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; const app = express(); app.use(express.json()); +// Allow CORS all domains, expose the Mcp-Session-Id header +app.use(cors({ + origin: '*', // Allow all origins + exposedHeaders: ["Mcp-Session-Id"] +})); + // Set up OAuth if enabled let authMiddleware = null; if (useOAuth) { From 7c374fd81e67abc8889a6931cce702c6ae9fb628 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 12:40:28 +0100 Subject: [PATCH 140/208] Merged the CORS tips --- README.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 651893531..b91f004af 100644 --- a/README.md +++ b/README.md @@ -570,18 +570,7 @@ 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: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], -> exposedHeaders: ['mcp-session-id'], -> allowedHeaders: ['Content-Type', 'mcp-session-id'], -> }) -> ); +> 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. Read the following section for examples. > ``` @@ -594,8 +583,10 @@ import cors from 'cors'; // Add CORS middleware before your MCP routes app.use(cors({ - origin: '*', // Configure appropriately for production + origin: '*', // Configure appropriately for production, for example: + // origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], exposedHeaders: ['Mcp-Session-Id'] + allowedHeaders: ['Content-Type', 'mcp-session-id'], })); ``` From 98a04e6e38a8762c66e3f0c7d997d9656427ac77 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 7 Jul 2025 13:22:15 +0100 Subject: [PATCH 141/208] moved src/spec.types.ts to . --- .gitignore | 2 +- package.json | 2 +- src/spec.types.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b39dd94e9..694735b68 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,7 @@ web_modules/ *.tgz # Output of 'npm run fetch:spec-types' -src/spec.types.ts +spec.types.ts # Yarn Integrity file .yarn-integrity diff --git a/package.json b/package.json index b42b1ea23..bf21340f2 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dist" ], "scripts": { - "fetch:spec-types": "curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", + "fetch:spec-types": "curl -o spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", "build": "npm run build:esm && npm run build:cjs", "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", "build:esm:w": "npm run build:esm -- -w", diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 87514cb05..796aab1c1 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -6,7 +6,7 @@ * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) */ import * as SDKTypes from "./types.js"; -import * as SpecTypes from "./spec.types.js"; +import * as SpecTypes from "../spec.types.js"; import fs from "node:fs"; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -666,7 +666,7 @@ function checkLoggingLevel( } // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) -const SPEC_TYPES_FILE = 'src/spec.types.ts'; +const SPEC_TYPES_FILE = 'spec.types.ts'; const SDK_TYPES_FILE = 'src/types.ts'; const MISSING_SDK_TYPES = [ From 96a4f714e56be1ab5ba7fd985a3e6c5a0414ce0f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 7 Jul 2025 13:22:41 +0100 Subject: [PATCH 142/208] removed checkResourceReference (sdk's ResourceReference is deprecated / aliased to ResourceTemplateReference) --- src/spec.types.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 796aab1c1..09cd6c2d0 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -258,13 +258,6 @@ function checkPromptReference( sdk = spec; spec = sdk; } -function checkResourceReference( - sdk: RemovePassthrough, - spec: SpecTypes.ResourceTemplateReference -) { - sdk = spec; - spec = sdk; -} function checkToolAnnotations( sdk: RemovePassthrough, spec: SpecTypes.ToolAnnotations From afb89715695faf2280c786804fdbc8d650aabbd9 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:44:35 +0100 Subject: [PATCH 143/208] Add ondelete hook to StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional ondelete callback that fires when DELETE request is received, providing the sessionId for cleanup handling. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 022d1a474..bf3a93c9e 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -133,6 +133,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string; onclose?: () => void; + ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -538,6 +539,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } + this.ondelete?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 52e22ad7cb4d7fc1197976929f2e305dc6d23579 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:57:41 +0100 Subject: [PATCH 144/208] Refactor ondelete hook to onsessionclosed in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed ondelete callback to onsessionclosed for better clarity - Moved callback from public property to private field following options pattern - Added comprehensive JSDoc documentation explaining the callback's purpose - Updated DELETE request handler to use the new callback structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bf3a93c9e..37164c869 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -49,6 +49,18 @@ export interface StreamableHTTPServerTransportOptions { */ onsessioninitialized?: (sessionId: string) => void; + /** + * A callback for session close events + * This is called when the server closes a session due to a DELETE request. + * Useful in cases when you need to clean up resources associated with the session. + * Note that this is different from the transport closing, if you are handling + * HTTP requests from multiple nodes you might want to close each + * StreamableHTTPServerTransport after a request is completed while still keeping the + * session open/running. + * @param sessionId The session ID that was closed + */ + onsessionclosed?: (sessionId: string) => void; + /** * If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. @@ -127,13 +139,13 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _onsessionclosed?: (sessionId: string) => void; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; sessionId?: string; onclose?: () => void; - ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -142,6 +154,7 @@ export class StreamableHTTPServerTransport implements Transport { this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._onsessionclosed = options.onsessionclosed; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; @@ -539,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } - this.ondelete?.(this.sessionId!); + this._onsessionclosed?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 61052b1fa89963c1cf5f038e2972fd85cf19f6a2 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 15:19:40 +0100 Subject: [PATCH 145/208] Add comprehensive tests for onsessionclosed callback in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Tests callback is called when session is closed via DELETE • Tests system works correctly when callback is not provided • Tests callback is not called for invalid session DELETE requests • Tests correct session ID is passed when multiple sessions exist • Follows existing test patterns and includes proper cleanup 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.test.ts | 166 +++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 502435ead..e54bea017 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -29,6 +29,7 @@ interface TestServerConfig { enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; + onsessionclosed?: (sessionId: string) => void; } /** @@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); }); +// Test onsessionclosed callback +describe("StreamableHTTPServerTransport onsessionclosed callback", () => { + it("should call onsessionclosed callback when session is closed via DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback when not provided", async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback for invalid session DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); + + // Clean up + tempServer.close(); + }); + + it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => { + const mockCallback = jest.fn(); + + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server1 = result1.server; + const url1 = result1.baseUrl; + + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server2 = result2.server; + const url2 = result2.baseUrl; + + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get("mcp-session-id"); + + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get("mcp-session-id"); + + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId1 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId2 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); + + // Clean up + server1.close(); + server2.close(); + }); +}); + // Test DNS rebinding protection describe("StreamableHTTPServerTransport DNS rebinding protection", () => { let server: Server; From 6ae4a5727c3c7ce1413f11af20c3ca2225f452ba Mon Sep 17 00:00:00 2001 From: anthonjn Date: Mon, 7 Jul 2025 11:54:44 -0700 Subject: [PATCH 146/208] add custom headers on initial _startOrAuth call (#318) * add custom headers on initial _startOrAuth call * update client/sse.ts: align commonHeaders w/ streamableHttp version --------- Co-authored-by: Olivier Chafik --- src/client/sse.test.ts | 23 +++++++++++++++++++++++ src/client/sse.ts | 23 ++++++++++------------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 2d1163449..3e3abe68f 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -382,6 +382,29 @@ describe("SSEClientTransport", () => { expect(mockAuthProvider.tokens).toHaveBeenCalled(); }); + it("attaches custom header from provider on initial SSE connection", async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer" + }); + const customHeaders = { + "X-Custom-Header": "custom-value", + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + requestInit: { + headers: customHeaders, + }, + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe("Bearer test-token"); + expect(lastServerRequest.headers["x-custom-header"]).toBe("custom-value"); + expect(mockAuthProvider.tokens).toHaveBeenCalled(); + }); + it("attaches auth header from provider on POST requests", async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: "test-token", diff --git a/src/client/sse.ts b/src/client/sse.ts index faffecc41..568a51592 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -106,10 +106,8 @@ export class SSEClientTransport implements Transport { return await this._startOrAuth(); } - private async _commonHeaders(): Promise { - const headers = { - ...this._requestInit?.headers, - } as HeadersInit & Record; + private async _commonHeaders(): Promise { + const headers: HeadersInit = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -120,24 +118,24 @@ export class SSEClientTransport implements Transport { headers["mcp-protocol-version"] = this._protocolVersion; } - return headers; + return new Headers( + { ...headers, ...this._requestInit?.headers } + ); } private _startOrAuth(): Promise { -const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch + const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, { ...this._eventSourceInit, fetch: async (url, init) => { - const headers = await this._commonHeaders() + const headers = await this._commonHeaders(); + headers.set("Accept", "text/event-stream"); const response = await fetchImpl(url, { ...init, - headers: new Headers({ - ...headers, - Accept: "text/event-stream" - }) + headers, }) if (response.status === 401 && response.headers.has('www-authenticate')) { @@ -238,8 +236,7 @@ const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typ } try { - const commonHeaders = await this._commonHeaders(); - const headers = new Headers(commonHeaders); + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); const init = { ...this._requestInit, From 60310e9478cfc58534d2ed242f21d4a08036d463 Mon Sep 17 00:00:00 2001 From: HoberMin <102784200+HoberMin@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:39:02 +0900 Subject: [PATCH 147/208] feat: Add error handling tests for InMemoryTransport --- src/inMemory.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++++- src/inMemory.ts | 4 +- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index baf43446c..0b0d5fe50 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -96,10 +96,43 @@ describe("InMemoryTransport", () => { }); test("should throw error when sending after close", async () => { - await clientTransport.close(); + const [client, server] = InMemoryTransport.createLinkedPair(); + let clientError: Error | undefined; + let serverError: Error | undefined; + + client.onerror = (err) => { + clientError = err; + }; + + server.onerror = (err) => { + serverError = err; + }; + + await client.close(); + + // Attempt to send message from client await expect( - clientTransport.send({ jsonrpc: "2.0", method: "test", id: 1 }), + client.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), ).rejects.toThrow("Not connected"); + + // Attempt to send message from server + await expect( + server.send({ + jsonrpc: "2.0", + method: "test", + id: 2, + }), + ).rejects.toThrow("Not connected"); + + // Verify that both sides received errors + expect(clientError).toBeDefined(); + expect(clientError?.message).toBe("Not connected"); + expect(serverError).toBeDefined(); + expect(serverError?.message).toBe("Not connected"); }); test("should queue messages sent before start", async () => { @@ -118,4 +151,65 @@ describe("InMemoryTransport", () => { await serverTransport.start(); expect(receivedMessage).toEqual(message); }); + + describe("error handling", () => { + test("should trigger onerror when sending without connection", async () => { + const transport = new InMemoryTransport(); + let error: Error | undefined; + + transport.onerror = (err) => { + error = err; + }; + + await expect( + transport.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), + ).rejects.toThrow("Not connected"); + + expect(error).toBeDefined(); + expect(error?.message).toBe("Not connected"); + }); + + test("should trigger onerror when sending after close", async () => { + const [client, server] = InMemoryTransport.createLinkedPair(); + let clientError: Error | undefined; + let serverError: Error | undefined; + + client.onerror = (err) => { + clientError = err; + }; + + server.onerror = (err) => { + serverError = err; + }; + + await client.close(); + + // Attempt to send message from client + await expect( + client.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), + ).rejects.toThrow("Not connected"); + + // Attempt to send message from server + await expect( + server.send({ + jsonrpc: "2.0", + method: "test", + id: 2, + }), + ).rejects.toThrow("Not connected"); + + // Verify that both sides received errors + expect(clientError?.message).toBe("Not connected"); + expect(serverError).toBeDefined(); + expect(serverError?.message).toBe("Not connected"); + }); + }); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 5dd6e81e0..056a4718d 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -51,7 +51,9 @@ export class InMemoryTransport implements Transport { */ async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { - throw new Error("Not connected"); + const error = new Error("Not connected"); + this.onerror?.(error); + throw error; } if (this._otherTransport.onmessage) { From a7dc1258501cde8cc4a20fb7415c82b18e3bb78b Mon Sep 17 00:00:00 2001 From: HoberMin <102784200+HoberMin@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:27:19 +0900 Subject: [PATCH 148/208] feat: improve stdio test Windows compatibility and refactor command logic --- src/client/stdio.test.ts | 6 ++---- src/client/stdio.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index b21324469..8c3786eb0 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,9 +1,7 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; +import { StdioClientTransport, getDefaultServerParameters } from "./stdio.js"; -const serverParameters: StdioServerParameters = { - command: "/usr/bin/tee", -}; +const serverParameters = getDefaultServerParameters(); test("should start then close cleanly", async () => { const client = new StdioClientTransport(serverParameters); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 62292ce10..a34e8f196 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -39,6 +39,15 @@ export type StdioServerParameters = { cwd?: string; }; +// Configure default server parameters based on OS +// Uses 'more' command for Windows and 'tee' command for Unix/Linux +export const getDefaultServerParameters = (): StdioServerParameters => { + if (process.platform === "win32") { + return { command: "more" }; + } + return { command: "/usr/bin/tee" }; +}; + /** * Environment variables to inherit by default, if an environment is not explicitly given. */ From 88ede3783e65c6168d5f9237e136e52a116b1a1b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 8 Jul 2025 15:15:50 +0100 Subject: [PATCH 149/208] Address review feedback: move getDefaultServerParameters to test file - Moved getDefaultServerParameters from stdio.ts to stdio.test.ts since it's only used in tests - Reverted unrelated changes to inMemory.ts and inMemory.test.ts - Kept the core Windows compatibility fix for stdio tests Co-authored-by: HoberMin <99346635+HoberMin@users.noreply.github.com> --- src/client/stdio.test.ts | 11 ++++- src/client/stdio.ts | 9 ---- src/inMemory.test.ts | 98 +--------------------------------------- src/inMemory.ts | 4 +- 4 files changed, 13 insertions(+), 109 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 8c3786eb0..2e4d92c25 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,5 +1,14 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, getDefaultServerParameters } from "./stdio.js"; +import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; + +// Configure default server parameters based on OS +// Uses 'more' command for Windows and 'tee' command for Unix/Linux +const getDefaultServerParameters = (): StdioServerParameters => { + if (process.platform === "win32") { + return { command: "more" }; + } + return { command: "/usr/bin/tee" }; +}; const serverParameters = getDefaultServerParameters(); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index a34e8f196..62292ce10 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -39,15 +39,6 @@ export type StdioServerParameters = { cwd?: string; }; -// Configure default server parameters based on OS -// Uses 'more' command for Windows and 'tee' command for Unix/Linux -export const getDefaultServerParameters = (): StdioServerParameters => { - if (process.platform === "win32") { - return { command: "more" }; - } - return { command: "/usr/bin/tee" }; -}; - /** * Environment variables to inherit by default, if an environment is not explicitly given. */ diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index 0b0d5fe50..baf43446c 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -96,43 +96,10 @@ describe("InMemoryTransport", () => { }); test("should throw error when sending after close", async () => { - const [client, server] = InMemoryTransport.createLinkedPair(); - let clientError: Error | undefined; - let serverError: Error | undefined; - - client.onerror = (err) => { - clientError = err; - }; - - server.onerror = (err) => { - serverError = err; - }; - - await client.close(); - - // Attempt to send message from client - await expect( - client.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), - ).rejects.toThrow("Not connected"); - - // Attempt to send message from server + await clientTransport.close(); await expect( - server.send({ - jsonrpc: "2.0", - method: "test", - id: 2, - }), + clientTransport.send({ jsonrpc: "2.0", method: "test", id: 1 }), ).rejects.toThrow("Not connected"); - - // Verify that both sides received errors - expect(clientError).toBeDefined(); - expect(clientError?.message).toBe("Not connected"); - expect(serverError).toBeDefined(); - expect(serverError?.message).toBe("Not connected"); }); test("should queue messages sent before start", async () => { @@ -151,65 +118,4 @@ describe("InMemoryTransport", () => { await serverTransport.start(); expect(receivedMessage).toEqual(message); }); - - describe("error handling", () => { - test("should trigger onerror when sending without connection", async () => { - const transport = new InMemoryTransport(); - let error: Error | undefined; - - transport.onerror = (err) => { - error = err; - }; - - await expect( - transport.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), - ).rejects.toThrow("Not connected"); - - expect(error).toBeDefined(); - expect(error?.message).toBe("Not connected"); - }); - - test("should trigger onerror when sending after close", async () => { - const [client, server] = InMemoryTransport.createLinkedPair(); - let clientError: Error | undefined; - let serverError: Error | undefined; - - client.onerror = (err) => { - clientError = err; - }; - - server.onerror = (err) => { - serverError = err; - }; - - await client.close(); - - // Attempt to send message from client - await expect( - client.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), - ).rejects.toThrow("Not connected"); - - // Attempt to send message from server - await expect( - server.send({ - jsonrpc: "2.0", - method: "test", - id: 2, - }), - ).rejects.toThrow("Not connected"); - - // Verify that both sides received errors - expect(clientError?.message).toBe("Not connected"); - expect(serverError).toBeDefined(); - expect(serverError?.message).toBe("Not connected"); - }); - }); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 056a4718d..5dd6e81e0 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -51,9 +51,7 @@ export class InMemoryTransport implements Transport { */ async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { - const error = new Error("Not connected"); - this.onerror?.(error); - throw error; + throw new Error("Not connected"); } if (this._otherTransport.onmessage) { From 4056c098a18a30fafa8aada15f9181b59697f4c1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 11:23:24 +0100 Subject: [PATCH 150/208] Add missing app.listen error handling to server examples --- src/cli.ts | 6 +++++- src/examples/server/demoInMemoryOAuthProvider.ts | 6 +++++- src/examples/server/jsonResponseStreamableHttp.ts | 6 +++++- src/examples/server/simpleSseServer.ts | 6 +++++- src/examples/server/simpleStatelessStreamableHttp.ts | 6 +++++- src/examples/server/simpleStreamableHttp.ts | 6 +++++- src/examples/server/sseAndStreamableHttpCompatibleServer.ts | 6 +++++- src/examples/server/standaloneSseWithGetStreamableHttp.ts | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b5000896d..f580a624f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -102,7 +102,11 @@ async function runServer(port: number | null) { await transport.handlePostMessage(req, res); }); - app.listen(port, () => { + app.listen(port, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Server running on http://localhost:${port}/sse`); }); } else { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 274a504a1..c83748d35 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -200,7 +200,11 @@ export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: { const auth_port = authServerUrl.port; // Start the auth server - authApp.listen(auth_port, () => { + authApp.listen(auth_port, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`OAuth Authorization Server listening on port ${auth_port}`); }); diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 04b14470b..d6501d275 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -158,7 +158,11 @@ app.get('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index c34179206..f8bdd4662 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -145,7 +145,11 @@ app.post('/messages', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index d235265cd..b5a1e291e 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -158,7 +158,11 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 029fff77a..98f9d351c 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -648,7 +648,11 @@ if (useOAuth && authMiddleware) { app.delete('/mcp', mcpDeleteHandler); } -app.listen(MCP_PORT, () => { +app.listen(MCP_PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 7b18578a5..e097ca70e 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -210,7 +210,11 @@ app.post("/messages", async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Backwards compatible MCP server listening on port ${PORT}`); console.log(` ============================================== diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 8c8c3baaa..279818139 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -112,7 +112,11 @@ app.get('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Server listening on port ${PORT}`); }); From 0ddf6827b6e9535f78102384778d7f5b643576fe Mon Sep 17 00:00:00 2001 From: Jerome Date: Wed, 9 Jul 2025 14:48:40 +0100 Subject: [PATCH 151/208] feat: support async callbacks for onsessioninitialized and onsessionclosed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated StreamableHTTPServerTransport to support both sync and async callbacks: - Changed callback types to return void | Promise - Used await Promise.resolve() to handle both sync and async callbacks - Added comprehensive tests for async callback functionality - Errors in callbacks now properly propagate to HTTP responses 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.test.ts | 212 +++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 12 +- 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index e54bea017..3a0a5c066 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -29,7 +29,8 @@ interface TestServerConfig { enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; - onsessionclosed?: (sessionId: string) => void; + onsessioninitialized?: (sessionId: string) => void | Promise; + onsessionclosed?: (sessionId: string) => void | Promise; } /** @@ -59,6 +60,7 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, onsessionclosed: config.onsessionclosed }); @@ -114,6 +116,7 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, onsessionclosed: config.onsessionclosed }); @@ -1666,6 +1669,213 @@ describe("StreamableHTTPServerTransport onsessionclosed callback", () => { }); }); +// Test async callbacks for onsessioninitialized and onsessionclosed +describe("StreamableHTTPServerTransport async callbacks", () => { + it("should support async onsessioninitialized callback", async () => { + const initializationOrder: string[] = []; + + // Create server with async onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + initializationOrder.push('async-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + initializationOrder.push('async-end'); + initializationOrder.push(sessionId); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it("should support sync onsessioninitialized callback (backwards compatibility)", async () => { + const capturedSessionId: string[] = []; + + // Create server with sync onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + capturedSessionId.push(sessionId); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + expect(capturedSessionId).toEqual([tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it("should support async onsessionclosed callback", async () => { + const closureOrder: string[] = []; + + // Create server with async onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (sessionId: string) => { + closureOrder.push('async-close-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + closureOrder.push('async-close-end'); + closureOrder.push(sessionId); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it("should propagate errors from async onsessioninitialized callback", async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Create server with async onsessioninitialized callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (_sessionId: string) => { + throw new Error('Async initialization error'); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize should fail when callback throws + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(400); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it("should propagate errors from async onsessionclosed callback", async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Create server with async onsessionclosed callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (_sessionId: string) => { + throw new Error('Async closure error'); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // DELETE should fail when callback throws + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(500); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it("should handle both async callbacks together", async () => { + const events: string[] = []; + + // Create server with both async callbacks + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`initialized:${sessionId}`); + }, + onsessionclosed: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`closed:${sessionId}`); + }, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger first callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`initialized:${tempSessionId}`); + + // DELETE to trigger second callback + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`closed:${tempSessionId}`); + expect(events).toHaveLength(2); + + // Clean up + tempServer.close(); + }); +}); + // Test DNS rebinding protection describe("StreamableHTTPServerTransport DNS rebinding protection", () => { let server: Server; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 37164c869..3bf84e430 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -47,7 +47,7 @@ export interface StreamableHTTPServerTransportOptions { * and need to keep track of them. * @param sessionId The generated session ID */ - onsessioninitialized?: (sessionId: string) => void; + onsessioninitialized?: (sessionId: string) => void | Promise; /** * A callback for session close events @@ -59,7 +59,7 @@ export interface StreamableHTTPServerTransportOptions { * session open/running. * @param sessionId The session ID that was closed */ - onsessionclosed?: (sessionId: string) => void; + onsessionclosed?: (sessionId: string) => void | Promise; /** * If true, the server will return JSON responses instead of starting an SSE stream. @@ -138,8 +138,8 @@ export class StreamableHTTPServerTransport implements Transport { private _enableJsonResponse: boolean = false; private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; - private _onsessioninitialized?: (sessionId: string) => void; - private _onsessionclosed?: (sessionId: string) => void; + private _onsessioninitialized?: (sessionId: string) => void | Promise; + private _onsessionclosed?: (sessionId: string) => void | Promise; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; @@ -460,7 +460,7 @@ export class StreamableHTTPServerTransport implements Transport { // If we have a session ID and an onsessioninitialized handler, call it immediately // This is needed in cases where the server needs to keep track of multiple sessions if (this.sessionId && this._onsessioninitialized) { - this._onsessioninitialized(this.sessionId); + await Promise.resolve(this._onsessioninitialized(this.sessionId)); } } @@ -552,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } - this._onsessionclosed?.(this.sessionId!); + await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); await this.close(); res.writeHead(200).end(); } From 8714f21541ca7b8f9ab20041b8019ed78d8ccf94 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 9 Jul 2025 07:43:33 -0700 Subject: [PATCH 152/208] fix(server): validate expiresAt token value for non existence (#446) * fix(server): validate expiresAt token value for non existence --------- Co-authored-by: Olivier Chafik --- src/server/auth/middleware/bearerAuth.test.ts | 43 +++++++++++++++++-- src/server/auth/middleware/bearerAuth.ts | 6 ++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index b8953e5c9..9b051b1af 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -37,6 +37,7 @@ describe("requireBearerAuth middleware", () => { token: "valid-token", clientId: "client-123", scopes: ["read", "write"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour }; mockVerifyAccessToken.mockResolvedValue(validAuthInfo); @@ -53,13 +54,17 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.status).not.toHaveBeenCalled(); expect(mockResponse.json).not.toHaveBeenCalled(); }); - - it("should reject expired tokens", async () => { + + it.each([ + [100], // Token expired 100 seconds ago + [0], // Token expires at the same time as now + ])("should reject expired tokens (expired %s seconds ago)", async (expiredSecondsAgo: number) => { + const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo; const expiredAuthInfo: AuthInfo = { token: "expired-token", clientId: "client-123", scopes: ["read", "write"], - expiresAt: Math.floor(Date.now() / 1000) - 100, // Token expired 100 seconds ago + expiresAt }; mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); @@ -82,6 +87,37 @@ describe("requireBearerAuth middleware", () => { expect(nextFunction).not.toHaveBeenCalled(); }); + it.each([ + [undefined], // Token has no expiration time + [NaN], // Token has no expiration time + ])("should reject tokens with no expiration time (expiresAt: %s)", async (expiresAt: number | undefined) => { + const noExpirationAuthInfo: AuthInfo = { + token: "no-expiration-token", + clientId: "client-123", + scopes: ["read", "write"], + expiresAt + }; + mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo); + + mockRequest.headers = { + authorization: "Bearer expired-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('Bearer error="invalid_token"') + ); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: "invalid_token", error_description: "Token has no expiration time" }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + it("should accept non-expired tokens", async () => { const nonExpiredAuthInfo: AuthInfo = { token: "valid-token", @@ -141,6 +177,7 @@ describe("requireBearerAuth middleware", () => { token: "valid-token", clientId: "client-123", scopes: ["read", "write", "admin"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour }; mockVerifyAccessToken.mockResolvedValue(authInfo); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 91f763a9b..7b6d8f61f 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -63,8 +63,10 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } } - // Check if the token is expired - if (!!authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { + // Check if the token is set to expire or if it is expired + if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { + throw new InvalidTokenError("Token has no expiration time"); + } else if (authInfo.expiresAt < Date.now() / 1000) { throw new InvalidTokenError("Token has expired"); } From 87b453d23fe7194920b061ebffab146f35b7d548 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 17:58:53 +0100 Subject: [PATCH 153/208] auth: fetch AS metadata in well-known subpath from serverUrl when PRM returns external AS Co-Authored-By: Claude --- src/client/auth.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 26 +++++++++++++----- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e1342..28adbe411 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1476,5 +1476,63 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); + + it("fetches AS metadata with path from serverUrl when PRM returns external AS", async () => { + // Mock PRM discovery that returns an external AS + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://my.resource.com/", + authorization_servers: ["https://auth.example.com/"], + }), + }); + } else if (urlString === "https://auth.example.com/.well-known/oauth-authorization-server/path/name") { + // Path-aware discovery on AS with path from serverUrl + 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 serverUrl that has a path + const result = await auth(mockProvider, { + serverUrl: "https://my.resource.com/path/name", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the correct URLs were fetched + const calls = mockFetch.mock.calls; + + // First call should be to PRM + expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource"); + + // Second call should be to AS metadata with the path from serverUrl + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a428..e84bbe187 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -122,7 +122,9 @@ export async function auth( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverOAuthMetadata(authorizationServerUrl); + const metadata = await discoverOAuthMetadata(serverUrl, { + authorizationServerUrl + }); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -354,15 +356,27 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) * return `undefined`. Any other errors will be thrown as exceptions. */ export async function discoverOAuthMetadata( - authorizationServerUrl: string | URL, - opts?: { protocolVersion?: string }, + issuer: string | URL, + { + authorizationServerUrl + }: { + authorizationServerUrl?: string | URL + } = {}, ): Promise { - const issuer = new URL(authorizationServerUrl); - const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + if (typeof issuer === 'string') { + issuer = new URL(issuer); + } + if (!authorizationServerUrl) { + authorizationServerUrl = issuer; + } + if (typeof authorizationServerUrl === 'string') { + authorizationServerUrl = new URL(authorizationServerUrl); + } + protocolVersion ??= LATEST_PROTOCOL_VERSION; // Try path-aware discovery first (RFC 8414 compliant) const wellKnownPath = buildWellKnownPath(issuer.pathname); - const pathAwareUrl = new URL(wellKnownPath, issuer); + const pathAwareUrl = new URL(wellKnownPath, authorizationServerUrl); let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); // If path-aware discovery fails with 404, try fallback to root discovery From c90537703b5bc2cf860038ea1c0996739b1ac7b0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 18:09:23 +0100 Subject: [PATCH 154/208] Update auth.ts Co-Authored-By: Claude --- src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index e84bbe187..3dee5fbdd 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -358,9 +358,9 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) export async function discoverOAuthMetadata( issuer: string | URL, { - authorizationServerUrl + authorizationServerUrl, }: { - authorizationServerUrl?: string | URL + authorizationServerUrl?: string | URL, } = {}, ): Promise { if (typeof issuer === 'string') { From 031dfc2835d275a833ea4ddc68579ef587ab9d84 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 18:31:09 +0100 Subject: [PATCH 155/208] [auth]: support oauth client_secret_basic / none / custom methods (#720) * Allow OAuthClientProvider to control authentication to token endpoint w/ addClientAuthentication (exchange / refresh) * Include mockProvider in exchangeAuthorization tests. * feature. Add client_secret_basic and none authentication methods --------- Co-authored-by: Jared Hanson Co-authored-by: SightStudio Co-authored-by: Claude --- src/client/auth.test.ts | 402 ++++++++++++++++++++++++++++++++++++++-- src/client/auth.ts | 209 ++++++++++++++++++--- 2 files changed, 569 insertions(+), 42 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e1342..75cf20b91 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -10,6 +10,7 @@ import { auth, type OAuthClientProvider, } from "./auth.js"; +import { OAuthMetadata } from '../shared/auth.js'; // Mock fetch globally const mockFetch = jest.fn(); @@ -231,7 +232,7 @@ describe("OAuth Authorization", () => { ok: false, status: 404, }); - + // Second call (root fallback) succeeds mockFetch.mockResolvedValueOnce({ ok: true, @@ -241,17 +242,17 @@ describe("OAuth Authorization", () => { 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"); @@ -266,7 +267,7 @@ describe("OAuth Authorization", () => { ok: false, status: 404, }); - + // Second call (root fallback) also returns 404 mockFetch.mockResolvedValueOnce({ ok: false, @@ -275,7 +276,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); expect(metadata).toBeUndefined(); - + const calls = mockFetch.mock.calls; expect(calls.length).toBe(2); }); @@ -289,10 +290,10 @@ describe("OAuth Authorization", () => { 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"); }); @@ -306,10 +307,10 @@ describe("OAuth Authorization", () => { 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"); }); @@ -317,13 +318,13 @@ describe("OAuth Authorization", () => { 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, @@ -333,10 +334,10 @@ describe("OAuth Authorization", () => { 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"); @@ -600,6 +601,13 @@ describe("OAuth Authorization", () => { refresh_token: "refresh123", }; + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"] + }; + const validClientInfo = { client_id: "client123", client_secret: "secret123", @@ -629,9 +637,9 @@ describe("OAuth Authorization", () => { }), expect.objectContaining({ method: "POST", - headers: { + headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded", - }, + }), }) ); @@ -645,6 +653,52 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); + it("exchanges code for tokens with auth", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: OAuthMetadata) => { + headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret)); + params.set("example_url", typeof url === 'string' ? url : url.toString()); + params.set("example_metadata", metadata.authorization_endpoint); + params.set("example_param", "example_value"); + }, + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/token", + }), + expect.objectContaining({ + method: "POST", + }) + ); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw=="); + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("code123"); + expect(body.get("code_verifier")).toBe("verifier123"); + expect(body.get("client_id")).toBeNull(); + expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); + expect(body.get("example_url")).toBe("https://auth.example.com"); + expect(body.get("example_metadata")).toBe("https://auth.example.com/authorize"); + expect(body.get("example_param")).toBe("example_value"); + expect(body.get("client_secret")).toBeNull(); + }); + it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -693,6 +747,13 @@ describe("OAuth Authorization", () => { refresh_token: "newrefresh123", }; + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"] + }; + const validClientInfo = { client_id: "client123", client_secret: "secret123", @@ -720,9 +781,9 @@ describe("OAuth Authorization", () => { }), expect.objectContaining({ method: "POST", - headers: { + headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded", - }, + }), }) ); @@ -734,6 +795,48 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); + it("exchanges refresh token for new tokens with auth", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + const tokens = await refreshAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + refreshToken: "refresh123", + addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata) => { + headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret)); + params.set("example_url", typeof url === 'string' ? url : url.toString()); + params.set("example_metadata", metadata?.authorization_endpoint ?? '?'); + params.set("example_param", "example_value"); + }, + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://auth.example.com/token", + }), + expect.objectContaining({ + method: "POST", + }) + ); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + expect(headers.get("Authorization")).toBe("Basic Y2xpZW50MTIzOnNlY3JldDEyMw=="); + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + expect(body.get("client_id")).toBeNull(); + expect(body.get("example_url")).toBe("https://auth.example.com"); + expect(body.get("example_metadata")).toBe("https://auth.example.com/authorize"); + expect(body.get("example_param")).toBe("example_value"); + expect(body.get("client_secret")).toBeNull(); + }); + it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1477,4 +1580,267 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); }); + + describe("exchangeAuthorization with multiple client authentication methods", () => { + const validTokens = { + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }; + + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const metadataWithBasicOnly = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/auth", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }; + + const metadataWithPostOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ["client_secret_post"], + }; + + const metadataWithNoneOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ["none"], + }; + + const metadataWithAllBuiltinMethods = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"], + }; + + it("uses HTTP Basic authentication when client_secret_basic is supported", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: metadataWithBasicOnly, + clientInformation: validClientInfo, + authorizationCode: "code123", + redirectUri: "http://localhost:3000/callback", + codeVerifier: "verifier123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check Authorization header + const authHeader = request.headers.get("Authorization"); + const expected = "Basic " + btoa("client123:secret123"); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBeNull(); + expect(body.get("client_secret")).toBeNull(); + }); + + it("includes credentials in request body when client_secret_post is supported", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: metadataWithPostOnly, + clientInformation: validClientInfo, + authorizationCode: "code123", + redirectUri: "http://localhost:3000/callback", + codeVerifier: "verifier123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check no Authorization header + expect(request.headers.get("Authorization")).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + }); + + it("it picks client_secret_basic when all builtin methods are supported", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: metadataWithAllBuiltinMethods, + clientInformation: validClientInfo, + authorizationCode: "code123", + redirectUri: "http://localhost:3000/callback", + codeVerifier: "verifier123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check Authorization header - should use Basic auth as it's the most secure + const authHeader = request.headers.get("Authorization"); + const expected = "Basic " + btoa("client123:secret123"); + expect(authHeader).toBe(expected); + + // Credentials should not be in body when using Basic auth + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBeNull(); + expect(body.get("client_secret")).toBeNull(); + }); + + it("uses public client authentication when none method is specified", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const clientInfoWithoutSecret = { + client_id: "client123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: metadataWithNoneOnly, + clientInformation: clientInfoWithoutSecret, + authorizationCode: "code123", + redirectUri: "http://localhost:3000/callback", + codeVerifier: "verifier123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check no Authorization header + expect(request.headers.get("Authorization")).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBeNull(); + }); + + it("defaults to client_secret_post when no auth methods specified", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + redirectUri: "http://localhost:3000/callback", + codeVerifier: "verifier123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check headers + expect(request.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded"); + expect(request.headers.get("Authorization")).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + }); + }); + + describe("refreshAuthorization with multiple client authentication methods", () => { + const validTokens = { + access_token: "newaccess123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "newrefresh123", + }; + + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const metadataWithBasicOnly = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/auth", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }; + + const metadataWithPostOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ["client_secret_post"], + }; + + it("uses client_secret_basic for refresh token", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await refreshAuthorization("https://auth.example.com", { + metadata: metadataWithBasicOnly, + clientInformation: validClientInfo, + refreshToken: "refresh123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check Authorization header + const authHeader = request.headers.get("Authorization"); + const expected = "Basic " + btoa("client123:secret123"); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBeNull(); // should not be in body + expect(body.get("client_secret")).toBeNull(); // should not be in body + expect(body.get("refresh_token")).toBe("refresh123"); + }); + + it("uses client_secret_post for refresh token", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await refreshAuthorization("https://auth.example.com", { + metadata: metadataWithPostOnly, + clientInformation: validClientInfo, + refreshToken: "refresh123", + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0][1]; + + // Check no Authorization header + expect(request.headers.get("Authorization")).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + }); + }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 71101a428..8e32dc2f3 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -73,6 +73,26 @@ export interface OAuthClientProvider { */ codeVerifier(): string | Promise; + /** + * Adds custom client authentication to OAuth token requests. + * + * This optional method allows implementations to customize how client credentials + * are included in token exchange and refresh requests. When provided, this method + * is called instead of the default authentication logic, giving full control over + * the authentication mechanism. + * + * Common use cases include: + * - Supporting authentication methods beyond the standard OAuth 2.0 methods + * - Adding custom headers for proprietary authentication schemes + * - Implementing client assertion-based authentication (e.g., JWT bearer tokens) + * + * @param headers - The request headers (can be modified to add authentication) + * @param params - The request body parameters (can be modified to add credentials) + * @param url - The token endpoint URL being called + * @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods + */ + addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata): void | Promise; + /** * If defined, overrides the selection and validation of the * RFC 8707 Resource Indicator. If left undefined, default @@ -91,6 +111,114 @@ export class UnauthorizedError extends Error { } } +type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +/** + * Determines the best client authentication method to use based on server support and client configuration. + * + * Priority order (highest to lowest): + * 1. client_secret_basic (if client secret is available) + * 2. client_secret_post (if client secret is available) + * 3. none (for public clients) + * + * @param clientInformation - OAuth client information containing credentials + * @param supportedMethods - Authentication methods supported by the authorization server + * @returns The selected authentication method + */ +function selectClientAuthMethod( + clientInformation: OAuthClientInformation, + supportedMethods: string[] +): ClientAuthMethod { + const hasClientSecret = clientInformation.client_secret !== undefined; + + // If server doesn't specify supported methods, use RFC 6749 defaults + if (supportedMethods.length === 0) { + return hasClientSecret ? "client_secret_post" : "none"; + } + + // Try methods in priority order (most secure first) + if (hasClientSecret && supportedMethods.includes("client_secret_basic")) { + return "client_secret_basic"; + } + + if (hasClientSecret && supportedMethods.includes("client_secret_post")) { + return "client_secret_post"; + } + + if (supportedMethods.includes("none")) { + return "none"; + } + + // Fallback: use what we have + return hasClientSecret ? "client_secret_post" : "none"; +} + +/** + * Applies client authentication to the request based on the specified method. + * + * Implements OAuth 2.1 client authentication methods: + * - client_secret_basic: HTTP Basic authentication (RFC 6749 Section 2.3.1) + * - client_secret_post: Credentials in request body (RFC 6749 Section 2.3.1) + * - none: Public client authentication (RFC 6749 Section 2.1) + * + * @param method - The authentication method to use + * @param clientInformation - OAuth client information containing credentials + * @param headers - HTTP headers object to modify + * @param params - URL search parameters to modify + * @throws {Error} When required credentials are missing + */ +function applyClientAuthentication( + method: ClientAuthMethod, + clientInformation: OAuthClientInformation, + headers: Headers, + params: URLSearchParams +): void { + const { client_id, client_secret } = clientInformation; + + switch (method) { + case "client_secret_basic": + applyBasicAuth(client_id, client_secret, headers); + return; + case "client_secret_post": + applyPostAuth(client_id, client_secret, params); + return; + case "none": + applyPublicAuth(client_id, params); + return; + default: + throw new Error(`Unsupported client authentication method: ${method}`); + } +} + +/** + * Applies HTTP Basic authentication (RFC 6749 Section 2.3.1) + */ +function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void { + if (!clientSecret) { + throw new Error("client_secret_basic authentication requires a client_secret"); + } + + const credentials = btoa(`${clientId}:${clientSecret}`); + headers.set("Authorization", `Basic ${credentials}`); +} + +/** + * Applies POST body authentication (RFC 6749 Section 2.3.1) + */ +function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void { + params.set("client_id", clientId); + if (clientSecret) { + params.set("client_secret", clientSecret); + } +} + +/** + * Applies public client authentication (RFC 6749 Section 2.1) + */ +function applyPublicAuth(clientId: string, params: URLSearchParams): void { + params.set("client_id", clientId); +} + /** * Orchestrates the full auth flow with a server. * @@ -154,6 +282,7 @@ export async function auth( codeVerifier, redirectUri: provider.redirectUrl, resource, + addClientAuthentication: provider.addClientAuthentication, }); await provider.saveTokens(tokens); @@ -171,6 +300,7 @@ export async function auth( clientInformation, refreshToken: tokens.refresh_token, resource, + addClientAuthentication: provider.addClientAuthentication, }); await provider.saveTokens(newTokens); @@ -460,6 +590,15 @@ export async function startAuthorization( /** * Exchanges an authorization code for an access token with the given server. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Falls back to appropriate defaults when server metadata is unavailable + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, auth code, etc. + * @returns Promise resolving to OAuth tokens + * @throws {Error} When token exchange fails or authentication is invalid */ export async function exchangeAuthorization( authorizationServerUrl: string | URL, @@ -470,6 +609,7 @@ export async function exchangeAuthorization( codeVerifier, redirectUri, resource, + addClientAuthentication }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; @@ -477,37 +617,43 @@ export async function exchangeAuthorization( codeVerifier: string; redirectUri: string | URL; resource?: URL; + addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; }, ): Promise { const grantType = "authorization_code"; - let tokenUrl: URL; - if (metadata) { - tokenUrl = new URL(metadata.token_endpoint); + const tokenUrl = metadata?.token_endpoint + ? new URL(metadata.token_endpoint) + : new URL("/token", authorizationServerUrl); - if ( - metadata.grant_types_supported && + if ( + metadata?.grant_types_supported && !metadata.grant_types_supported.includes(grantType) - ) { - throw new Error( + ) { + throw new Error( `Incompatible auth server: does not support grant type ${grantType}`, - ); - } - } else { - tokenUrl = new URL("/token", authorizationServerUrl); + ); } // Exchange code for tokens + const headers = new Headers({ + "Content-Type": "application/x-www-form-urlencoded", + }); const params = new URLSearchParams({ grant_type: grantType, - client_id: clientInformation.client_id, code: authorizationCode, code_verifier: codeVerifier, redirect_uri: String(redirectUri), }); - if (clientInformation.client_secret) { - params.set("client_secret", clientInformation.client_secret); + if (addClientAuthentication) { + addClientAuthentication(headers, params, authorizationServerUrl, metadata); + } else { + // Determine and apply client authentication method + const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + + applyClientAuthentication(authMethod, clientInformation, headers, params); } if (resource) { @@ -516,9 +662,7 @@ export async function exchangeAuthorization( const response = await fetch(tokenUrl, { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, + headers, body: params, }); @@ -531,6 +675,15 @@ export async function exchangeAuthorization( /** * Exchange a refresh token for an updated access token. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Preserves the original refresh token if a new one is not returned + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, refresh token, etc. + * @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced) + * @throws {Error} When token refresh fails or authentication is invalid */ export async function refreshAuthorization( authorizationServerUrl: string | URL, @@ -539,12 +692,14 @@ export async function refreshAuthorization( clientInformation, refreshToken, resource, + addClientAuthentication, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; resource?: URL; - }, + addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; + } ): Promise { const grantType = "refresh_token"; @@ -565,14 +720,22 @@ export async function refreshAuthorization( } // Exchange refresh token + const headers = new Headers({ + "Content-Type": "application/x-www-form-urlencoded", + }); const params = new URLSearchParams({ grant_type: grantType, - client_id: clientInformation.client_id, refresh_token: refreshToken, }); - if (clientInformation.client_secret) { - params.set("client_secret", clientInformation.client_secret); + if (addClientAuthentication) { + addClientAuthentication(headers, params, authorizationServerUrl, metadata); + } else { + // Determine and apply client authentication method + const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + + applyClientAuthentication(authMethod, clientInformation, headers, params); } if (resource) { @@ -581,9 +744,7 @@ export async function refreshAuthorization( const response = await fetch(tokenUrl, { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, + headers, body: params, }); if (!response.ok) { From 72cb9a700f7304a54ba075f610e27fbc1fdc8d54 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Jul 2025 09:30:12 +0100 Subject: [PATCH 156/208] add tests --- src/client/auth.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8155e1342..4c643f6c3 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -177,6 +177,36 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) .rejects.toThrow(); }); + + it("returns metadata when discovery succeeds with path", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name"); + }); + + it("preserves query parameters in path-aware discovery", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path?param=value"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value"); + }); }); describe("discoverOAuthMetadata", () => { From 5eacdf174c61607582d5d0858f4eb841909c2efc Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Jul 2025 11:36:57 +0100 Subject: [PATCH 157/208] fallback and refactor --- src/client/auth.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 93 ++++++++++++++------------- 2 files changed, 188 insertions(+), 43 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 4c643f6c3..c1526d82e 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,144 @@ describe("OAuth Authorization", () => { const [url] = calls[0]; expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value"); }); + + 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 discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource/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://resource.example.com/.well-known/oauth-protected-resource"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("throws error 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, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) + .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + 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, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/")) + .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + }); + + 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, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + }); + + 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 discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("does not fallback when resourceMetadataUrl is provided", async () => { + // Call with explicit URL returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", { + resourceMetadataUrl: "https://custom.example.com/metadata" + })).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided + + const [url] = calls[0]; + expect(url.toString()).toBe("https://custom.example.com/metadata"); + }); }); describe("discoverOAuthMetadata", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index eb3473ada..90d7eb625 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -107,12 +107,13 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL + }): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -197,7 +198,7 @@ export async function auth( return "REDIRECT"; } -export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { +export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { const defaultResource = resourceUrlFromServerUrl(serverUrl); // If provider has custom validation, delegate to it @@ -256,34 +257,16 @@ export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, ): Promise { + const response = await discoverMetadataWithFallback( + serverUrl, + 'oauth-protected-resource', + { + protocolVersion: opts?.protocolVersion, + metadataUrl: opts?.resourceMetadataUrl, + }, + ); - let url: URL - if (opts?.resourceMetadataUrl) { - url = new URL(opts?.resourceMetadataUrl); - } else { - const issuer = new URL(serverUrl); - const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname); - url = new URL(wellKnownPath, issuer); - url.search = issuer.search; - } - - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION - } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; - } - } - - if (response.status === 404) { + if (!response || response.status === 404) { throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); } @@ -350,6 +333,38 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) return !response || response.status === 404 && pathname !== '/'; } +/** + * Generic function for discovering OAuth metadata with fallback support + */ +async function discoverMetadataWithFallback( + serverUrl: string | URL, + wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + opts?: { protocolVersion?: string; metadataUrl?: string | URL }, +): Promise { + const issuer = new URL(serverUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + + let url: URL; + if (opts?.metadataUrl) { + url = new URL(opts.metadataUrl); + } else { + // Try path-aware discovery first + const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); + url = new URL(wellKnownPath, issuer); + url.search = issuer.search; + } + + let response = await tryMetadataDiscovery(url, protocolVersion); + + // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery + if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { + const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + } + + return response; +} + /** * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. * @@ -360,20 +375,12 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const issuer = new URL(authorizationServerUrl); - const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - - // Try path-aware discovery first (RFC 8414 compliant) - const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname); - const pathAwareUrl = new URL(wellKnownPath, issuer); - pathAwareUrl.search = issuer.search; - let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); + const response = await discoverMetadataWithFallback( + authorizationServerUrl, + 'oauth-authorization-server', + opts, + ); - // If path-aware discovery fails with 404, try fallback to root discovery - if (shouldAttemptFallback(response, issuer.pathname)) { - const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion); - } if (!response || response.status === 404) { return undefined; } From de1ade29d45fc23de314d9bfe34e98c1c3b8e16d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:25:21 +0100 Subject: [PATCH 158/208] fix unmerge --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index b54261d30..f77efe88a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -489,8 +489,10 @@ export async function discoverOAuthMetadata( issuer: string | URL, { authorizationServerUrl, + protocolVersion, }: { authorizationServerUrl?: string | URL, + protocolVersion?: string, } = {}, ): Promise { if (typeof issuer === 'string') { From 1b8d63ed6d9109ddad3307343500d1493bf8e024 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:36:34 +0100 Subject: [PATCH 159/208] add metadataServerUrl to discoverMetadataWithFallback to allow external AS --- src/client/auth.test.ts | 4 ++-- src/client/auth.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index d8bee458b..93dd8e941 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1753,7 +1753,7 @@ describe("OAuth Authorization", () => { mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource") { + if (urlString === "https://my.resource.com/.well-known/oauth-protected-resource/path/name") { return Promise.resolve({ ok: true, status: 200, @@ -1800,7 +1800,7 @@ describe("OAuth Authorization", () => { const calls = mockFetch.mock.calls; // First call should be to PRM - expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource"); + expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name"); // Second call should be to AS metadata with the path from serverUrl expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); diff --git a/src/client/auth.ts b/src/client/auth.ts index a5b47aed5..64b472408 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -471,7 +471,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', - opts?: { protocolVersion?: string; metadataUrl?: string | URL }, + opts?: { protocolVersion?: string; metadataUrl?: string | URL, metadataServerUrl?: string | URL }, ): Promise { const issuer = new URL(serverUrl); const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; @@ -482,7 +482,7 @@ async function discoverMetadataWithFallback( } else { // Try path-aware discovery first const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); - url = new URL(wellKnownPath, issuer); + url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); url.search = issuer.search; } @@ -525,9 +525,13 @@ export async function discoverOAuthMetadata( protocolVersion ??= LATEST_PROTOCOL_VERSION; const response = await discoverMetadataWithFallback( - authorizationServerUrl, + issuer, + // authorizationServerUrl, 'oauth-authorization-server', - {protocolVersion}, + { + protocolVersion, + metadataServerUrl: authorizationServerUrl, + }, ); if (!response || response.status === 404) { From 1e32f146c8d4edef827ac0f3905d586ff954211d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 10 Jul 2025 14:37:10 +0100 Subject: [PATCH 160/208] Update auth.ts --- src/client/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 64b472408..2b69a5d8f 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -526,7 +526,6 @@ export async function discoverOAuthMetadata( const response = await discoverMetadataWithFallback( issuer, - // authorizationServerUrl, 'oauth-authorization-server', { protocolVersion, From c6ac083b1b37b222b5bfba5563822daa5d03372e Mon Sep 17 00:00:00 2001 From: Jerome Date: Thu, 10 Jul 2025 16:20:14 +0100 Subject: [PATCH 161/208] Bumped version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e381dd12b..24ba826b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.15.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 5b99c24bda03b4f051b244e4242cfffb14cea9e6 Mon Sep 17 00:00:00 2001 From: Daniel Kelleher Date: Mon, 14 Jul 2025 14:22:04 +0200 Subject: [PATCH 162/208] Add OIDC ID token support (#680) --- src/server/auth/handlers/token.test.ts | 56 +++++++++++++++++--------- src/shared/auth.ts | 1 + 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 4b7fae025..946cc6910 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -16,6 +16,18 @@ jest.mock('pkce-challenge', () => ({ }) })); +const mockTokens = { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' +}; + +const mockTokensWithIdToken = { + ...mockTokens, + id_token: 'mock_id_token' +} + describe('Token Handler', () => { // Mock client data const validClient: OAuthClientInformationFull = { @@ -58,12 +70,7 @@ describe('Token Handler', () => { async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { if (authorizationCode === 'valid_code') { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; + return mockTokens; } throw new InvalidGrantError('The authorization code is invalid or has expired'); }, @@ -291,18 +298,36 @@ describe('Token Handler', () => { ); }); + it('returns id token in code exchange if provided', async () => { + mockProvider.exchangeAuthorizationCode = async (client: OAuthClientInformationFull, authorizationCode: string): Promise => { + if (authorizationCode === 'valid_code') { + return mockTokensWithIdToken; + } + throw new InvalidGrantError('The authorization code is invalid or has expired'); + }; + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(response.body.id_token).toBe('mock_id_token'); + }); + it('passes through code verifier when using proxy provider', async () => { const originalFetch = global.fetch; try { global.fetch = jest.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }) + json: () => Promise.resolve(mockTokens) }); const proxyProvider = new ProxyOAuthServerProvider({ @@ -359,12 +384,7 @@ describe('Token Handler', () => { try { global.fetch = jest.fn().mockResolvedValue({ ok: true, - json: () => Promise.resolve({ - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }) + json: () => Promise.resolve(mockTokens) }); const proxyProvider = new ProxyOAuthServerProvider({ diff --git a/src/shared/auth.ts b/src/shared/auth.ts index b906de3d7..467680a56 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -62,6 +62,7 @@ export const OAuthMetadataSchema = z export const OAuthTokensSchema = z .object({ access_token: z.string(), + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect token_type: z.string(), expires_in: z.number().optional(), scope: z.string().optional(), From a9c907dd700a5c4b977624ac47fd8cc24c6bd473 Mon Sep 17 00:00:00 2001 From: Daniel Kelleher Date: Mon, 14 Jul 2025 15:47:09 +0200 Subject: [PATCH 163/208] Add prompt=consent for OIDC offline_access scope (#681) --- src/client/auth.test.ts | 14 ++++++++++++++ src/client/auth.ts | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 93dd8e941..3d2f18d74 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -714,6 +714,20 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.has("state")).toBe(false); }); + // OpenID Connect requires that the user is prompted for consent if the scope includes 'offline_access' + it("includes consent prompt parameter if scope includes 'offline_access'", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read write profile offline_access", + } + ); + + expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); + }); + it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", diff --git a/src/client/auth.ts b/src/client/auth.ts index 2b69a5d8f..2bac386f4 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -614,6 +614,13 @@ export async function startAuthorization( authorizationUrl.searchParams.set("scope", scope); } + if (scope?.includes("offline_access")) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + authorizationUrl.searchParams.append("prompt", "consent"); + } + if (resource) { authorizationUrl.searchParams.set("resource", resource.href); } From 117781453c16dd1afba80b11d43e938614bee957 Mon Sep 17 00:00:00 2001 From: Nitin Bansal Date: Mon, 14 Jul 2025 21:11:10 +0530 Subject: [PATCH 164/208] Non-critical: Readme syntax and typographical error fixes (#765) --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b91f004af..ec1788f18 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ The Model Context Protocol allows applications to provide context for LLMs in a npm install @modelcontextprotocol/sdk ``` -> ⚠️ MCP requires Node v18.x up to work fine. +> ⚠️ MCP requires Node.js v18.x or higher to work fine. ## Quick Start @@ -584,8 +584,8 @@ import cors from 'cors'; // Add CORS middleware before your MCP routes app.use(cors({ origin: '*', // Configure appropriately for production, for example: - // origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], - exposedHeaders: ['Mcp-Session-Id'] + // origin: ['https://your-remote-domain.com', 'https://your-other-remote-domain.com'], + exposedHeaders: ['Mcp-Session-Id'], allowedHeaders: ['Content-Type', 'mcp-session-id'], })); ``` @@ -876,7 +876,7 @@ const putMessageTool = server.tool( "putMessage", { channel: z.string(), message: z.string() }, async ({ channel, message }) => ({ - content: [{ type: "text", text: await putMessage(channel, string) }] + content: [{ type: "text", text: await putMessage(channel, message) }] }) ); // Until we upgrade auth, `putMessage` is disabled (won't show up in listTools) @@ -884,7 +884,7 @@ putMessageTool.disable() const upgradeAuthTool = server.tool( "upgradeAuth", - { permission: z.enum(["write', admin"])}, + { permission: z.enum(["write", "admin"])}, // Any mutations here will automatically emit `listChanged` notifications async ({ permission }) => { const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) @@ -1175,7 +1175,7 @@ This setup allows you to: ### Backwards Compatibility -Clients and servers with StreamableHttp tranport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows +Clients and servers with StreamableHttp transport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows #### Client-Side Compatibility From cc9ea7fd21db206352bbb7d619cf0f66b536945d Mon Sep 17 00:00:00 2001 From: Christian Daguerre Date: Mon, 14 Jul 2025 18:07:50 +0200 Subject: [PATCH 165/208] make client side client_id generation configurable in the oauth router (#734) --- src/server/auth/clients.ts | 2 +- src/server/auth/handlers/register.test.ts | 20 ++++++++++++++++++++ src/server/auth/handlers/register.ts | 18 ++++++++++++++---- src/server/auth/router.ts | 2 +- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/server/auth/clients.ts b/src/server/auth/clients.ts index 1b61a4de8..3b9110d5e 100644 --- a/src/server/auth/clients.ts +++ b/src/server/auth/clients.ts @@ -16,5 +16,5 @@ export interface OAuthRegisteredClientsStore { * * If unimplemented, dynamic client registration is unsupported. */ - registerClient?(client: OAuthClientInformationFull): OAuthClientInformationFull | Promise; + registerClient?(client: Omit): OAuthClientInformationFull | Promise; } \ No newline at end of file diff --git a/src/server/auth/handlers/register.test.ts b/src/server/auth/handlers/register.test.ts index a961f6543..d95e6d827 100644 --- a/src/server/auth/handlers/register.test.ts +++ b/src/server/auth/handlers/register.test.ts @@ -218,6 +218,26 @@ describe('Client Registration Handler', () => { expect(response.body.client_secret_expires_at).toBe(0); }); + it('sets no client_id when clientIdGeneration=false', async () => { + // Create handler with no expiry + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientIdGeneration: false + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + expect(response.body.client_id).toBeUndefined(); + }); + it('handles client with all metadata fields', async () => { const fullClientMetadata: OAuthClientMetadata = { redirect_uris: ['https://example.com/callback'], diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index c31373484..197e00533 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -31,6 +31,13 @@ export type ClientRegistrationHandlerOptions = { * Registration endpoints are particularly sensitive to abuse and should be rate limited. */ rateLimit?: Partial | false; + + /** + * Whether to generate a client ID before calling the client registration endpoint. + * + * If not set, defaults to true. + */ + clientIdGeneration?: boolean; }; const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days @@ -38,7 +45,8 @@ const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days export function clientRegistrationHandler({ clientsStore, clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, - rateLimit: rateLimitConfig + rateLimit: rateLimitConfig, + clientIdGeneration = true, }: ClientRegistrationHandlerOptions): RequestHandler { if (!clientsStore.registerClient) { throw new Error("Client registration store does not support registering clients"); @@ -78,7 +86,6 @@ export function clientRegistrationHandler({ const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none' // Generate client credentials - const clientId = crypto.randomUUID(); const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); @@ -89,14 +96,17 @@ export function clientRegistrationHandler({ const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0 const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime - let clientInfo: OAuthClientInformationFull = { + let clientInfo: Omit & { client_id?: string } = { ...clientMetadata, - client_id: clientId, client_secret: clientSecret, client_id_issued_at: clientIdIssuedAt, client_secret_expires_at: clientSecretExpiresAt, }; + if (clientIdGeneration) { + clientInfo.client_id = crypto.randomUUID(); + } + clientInfo = await clientsStore.registerClient!(clientInfo); res.status(201).json(clientInfo); } catch (error) { diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 3e752e7a8..a06bf73a1 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -142,7 +142,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { new URL(oauthMetadata.registration_endpoint).pathname, clientRegistrationHandler({ clientsStore: options.provider.clientsStore, - ...options, + ...options.clientRegistrationOptions, }) ); } From 4b2ad0385dc4fa65caa6d6dc6d81bf0bdb031cdb Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Tue, 15 Jul 2025 02:11:52 +1000 Subject: [PATCH 166/208] Adding `invalidateCredentials()` to `OAuthClientProvider` (#570) Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 53 +++--- src/client/auth.ts | 99 +++++++++- src/client/sse.test.ts | 173 ++++++++++++++++++ src/client/streamableHttp.test.ts | 158 ++++++++++++++++ src/server/auth/errors.ts | 104 ++++++----- src/server/auth/middleware/bearerAuth.test.ts | 4 +- 6 files changed, 508 insertions(+), 83 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 3d2f18d74..c5021def7 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -10,6 +10,7 @@ import { auth, type OAuthClientProvider, } from "./auth.js"; +import {ServerError} from "../server/auth/errors.js"; import { OAuthMetadata } from '../shared/auth.js'; // Mock fetch globally @@ -596,10 +597,7 @@ describe("OAuth Authorization", () => { }); it("throws on non-404 errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - }); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); await expect( discoverOAuthMetadata("https://auth.example.com") @@ -607,14 +605,15 @@ describe("OAuth Authorization", () => { }); it("validates metadata schema", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - // Missing required fields - issuer: "https://auth.example.com", - }), - }); + mockFetch.mockResolvedValueOnce( + Response.json( + { + // Missing required fields + issuer: "https://auth.example.com", + }, + { status: 200 } + ) + ); await expect( discoverOAuthMetadata("https://auth.example.com") @@ -902,10 +901,12 @@ describe("OAuth Authorization", () => { }); it("throws on error response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - }); + mockFetch.mockResolvedValueOnce( + Response.json( + new ServerError("Token exchange failed").toResponseObject(), + { status: 400 } + ) + ); await expect( exchangeAuthorization("https://auth.example.com", { @@ -1054,10 +1055,12 @@ describe("OAuth Authorization", () => { }); it("throws on error response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - }); + mockFetch.mockResolvedValueOnce( + Response.json( + new ServerError("Token refresh failed").toResponseObject(), + { status: 400 } + ) + ); await expect( refreshAuthorization("https://auth.example.com", { @@ -1142,10 +1145,12 @@ describe("OAuth Authorization", () => { }); it("throws on error response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - }); + mockFetch.mockResolvedValueOnce( + Response.json( + new ServerError("Dynamic client registration failed").toResponseObject(), + { status: 400 } + ) + ); await expect( registerClient("https://auth.example.com", { diff --git a/src/client/auth.ts b/src/client/auth.ts index 2bac386f4..47400768f 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -1,8 +1,24 @@ 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 { + OAuthClientMetadata, + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, + OAuthClientInformationFull, + OAuthProtectedResourceMetadata, + OAuthErrorResponseSchema +} from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; +import { + InvalidClientError, + InvalidGrantError, + OAUTH_ERRORS, + OAuthError, + ServerError, + UnauthorizedClientError +} from "../server/auth/errors.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -101,6 +117,13 @@ export interface OAuthClientProvider { * Implementations must verify the returned resource matches the MCP server. */ validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; + + /** + * If implemented, provides a way for the client to invalidate (e.g. delete) the specified + * credentials, in the case where the server has indicated that they are no longer valid. + * This avoids requiring the user to intervene manually. + */ + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -219,6 +242,33 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void { params.set("client_id", clientId); } +/** + * Parses an OAuth error response from a string or Response object. + * + * If the input is a standard OAuth2.0 error response, it will be parsed according to the spec + * and an instance of the appropriate OAuthError subclass will be returned. + * If parsing fails, it falls back to a generic ServerError that includes + * the response status (if available) and original content. + * + * @param input - A Response object or string containing the error response + * @returns A Promise that resolves to an OAuthError instance + */ +export async function parseErrorResponse(input: Response | string): Promise { + const statusCode = input instanceof Response ? input.status : undefined; + const body = input instanceof Response ? await input.text() : input; + + try { + const result = OAuthErrorResponseSchema.parse(JSON.parse(body)); + const { error, error_description, error_uri } = result; + const errorClass = OAUTH_ERRORS[error] || ServerError; + return new errorClass(error_description || '', error_uri); + } catch (error) { + // Not a valid OAuth error response, but try to inform the user of the raw data anyway + const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`; + return new ServerError(errorMessage); + } +} + /** * Orchestrates the full auth flow with a server. * @@ -226,6 +276,31 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void { * instead of linking together the other lower-level functions in this module. */ export async function auth( + provider: OAuthClientProvider, + options: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL }): Promise { + + try { + return await authInternal(provider, options); + } catch (error) { + // Handle recoverable error types by invalidating credentials and retrying + if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) { + await provider.invalidateCredentials?.('all'); + return await authInternal(provider, options); + } else if (error instanceof InvalidGrantError) { + await provider.invalidateCredentials?.('tokens'); + return await authInternal(provider, options); + } + + // Throw otherwise + throw error + } +} + +async function authInternal( provider: OAuthClientProvider, { serverUrl, authorizationCode, @@ -289,7 +364,7 @@ export async function auth( }); await provider.saveTokens(tokens); - return "AUTHORIZED"; + return "AUTHORIZED" } const tokens = await provider.tokens(); @@ -307,9 +382,15 @@ export async function auth( }); await provider.saveTokens(newTokens); - return "AUTHORIZED"; - } catch { - // Could not refresh OAuth tokens + return "AUTHORIZED" + } catch (error) { + // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. + if (!(error instanceof OAuthError) || error instanceof ServerError) { + // Could not refresh OAuth tokens + } else { + // Refresh failed for another reason, re-throw + throw error; + } } } @@ -327,7 +408,7 @@ export async function auth( await provider.saveCodeVerifier(codeVerifier); await provider.redirectToAuthorization(authorizationUrl); - return "REDIRECT"; + return "REDIRECT" } export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { @@ -707,7 +788,7 @@ export async function exchangeAuthorization( }); if (!response.ok) { - throw new Error(`Token exchange failed: HTTP ${response.status}`); + throw await parseErrorResponse(response); } return OAuthTokensSchema.parse(await response.json()); @@ -788,7 +869,7 @@ export async function refreshAuthorization( body: params, }); if (!response.ok) { - throw new Error(`Token refresh failed: HTTP ${response.status}`); + throw await parseErrorResponse(response); } return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); @@ -828,7 +909,7 @@ export async function registerClient( }); if (!response.ok) { - throw new Error(`Dynamic client registration failed: HTTP ${response.status}`); + throw await parseErrorResponse(response); } return OAuthClientInformationFullSchema.parse(await response.json()); diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 3e3abe68f..2cc4a1dd7 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -4,6 +4,7 @@ import { JSONRPCMessage } from "../types.js"; import { SSEClientTransport } from "./sse.js"; import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { OAuthTokens } from "../shared/auth.js"; +import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js"; describe("SSEClientTransport", () => { let resourceServer: Server; @@ -363,6 +364,7 @@ describe("SSEClientTransport", () => { redirectToAuthorization: jest.fn(), saveCodeVerifier: jest.fn(), codeVerifier: jest.fn(), + invalidateCredentials: jest.fn(), }; }); @@ -934,5 +936,176 @@ describe("SSEClientTransport", () => { await expect(() => transport.start()).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); }); + + it("invalidates all credentials on InvalidClientError during token refresh", async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "expired-token", + token_type: "Bearer", + refresh_token: "refresh-token" + }); + + let baseUrl = resourceBaseUrl; + + // Create server that returns InvalidClientError on token refresh + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + })); + return; + } + + if (req.url === "/token" && req.method === "POST") { + // Handle token refresh request - return InvalidClientError + const error = new InvalidClientError("Client authentication failed"); + res.writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify(error.toResponseObject())); + return; + } + + if (req.url !== "/") { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider, + }); + + await expect(() => transport.start()).rejects.toThrow(InvalidClientError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it("invalidates all credentials on UnauthorizedClientError during token refresh", async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "expired-token", + token_type: "Bearer", + refresh_token: "refresh-token" + }); + + let baseUrl = resourceBaseUrl; + + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + })); + return; + } + + if (req.url === "/token" && req.method === "POST") { + // Handle token refresh request - return UnauthorizedClientError + const error = new UnauthorizedClientError("Client not authorized"); + res.writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify(error.toResponseObject())); + return; + } + + if (req.url !== "/") { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider, + }); + + await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it("invalidates tokens on InvalidGrantError during token refresh", async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "expired-token", + token_type: "Bearer", + refresh_token: "refresh-token" + }); + let baseUrl = resourceBaseUrl; + + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === "/.well-known/oauth-authorization-server" && req.method === "GET") { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + })); + return; + } + + if (req.url === "/token" && req.method === "POST") { + // Handle token refresh request - return InvalidGrantError + const error = new InvalidGrantError("Invalid refresh token"); + res.writeHead(400, { 'Content-Type': 'application/json' }) + .end(JSON.stringify(error.toResponseObject())); + return; + } + + if (req.url !== "/") { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + await new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider, + }); + + await expect(() => transport.start()).rejects.toThrow(InvalidGrantError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); + }); }); }); diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index dcd76528d..218669f7b 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,6 +1,7 @@ import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js"; import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { JSONRPCMessage } from "../types.js"; +import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js"; describe("StreamableHTTPClientTransport", () => { @@ -17,6 +18,7 @@ describe("StreamableHTTPClientTransport", () => { redirectToAuthorization: jest.fn(), saveCodeVerifier: jest.fn(), codeVerifier: jest.fn(), + invalidateCredentials: jest.fn(), }; transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider }); jest.spyOn(global, "fetch"); @@ -592,4 +594,160 @@ describe("StreamableHTTPClientTransport", () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); + + it("invalidates all credentials on InvalidClientError during auth", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + refresh_token: "test-refresh" + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }; + (global.fetch as jest.Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "http://localhost:1234", + authorization_endpoint: "http://localhost:1234/authorize", + token_endpoint: "http://localhost:1234/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }) + // Token refresh fails with InvalidClientError + .mockResolvedValueOnce(Response.json( + new InvalidClientError("Client authentication failed").toResponseObject(), + { status: 400 } + )) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it("invalidates all credentials on UnauthorizedClientError during auth", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + refresh_token: "test-refresh" + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }; + (global.fetch as jest.Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "http://localhost:1234", + authorization_endpoint: "http://localhost:1234/authorize", + token_endpoint: "http://localhost:1234/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }) + // Token refresh fails with UnauthorizedClientError + .mockResolvedValueOnce(Response.json( + new UnauthorizedClientError("Client not authorized").toResponseObject(), + { status: 400 } + )) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it("invalidates tokens on InvalidGrantError during auth", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + refresh_token: "test-refresh" + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }; + (global.fetch as jest.Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "http://localhost:1234", + authorization_endpoint: "http://localhost:1234/authorize", + token_endpoint: "http://localhost:1234/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }) + // Token refresh fails with InvalidGrantError + .mockResolvedValueOnce(Response.json( + new InvalidGrantError("Invalid refresh token").toResponseObject(), + { status: 400 } + )) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); + }); }); diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 428199ce8..791b3b86c 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -4,8 +4,9 @@ import { OAuthErrorResponse } from "../../shared/auth.js"; * Base class for all OAuth errors */ export class OAuthError extends Error { + static errorCode: string; + constructor( - public readonly errorCode: string, message: string, public readonly errorUri?: string ) { @@ -28,6 +29,10 @@ export class OAuthError extends Error { return response; } + + get errorCode(): string { + return (this.constructor as typeof OAuthError).errorCode + } } /** @@ -36,9 +41,7 @@ export class OAuthError extends Error { * or is otherwise malformed. */ export class InvalidRequestError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_request", message, errorUri); - } + static errorCode = "invalid_request"; } /** @@ -46,9 +49,7 @@ export class InvalidRequestError extends OAuthError { * authentication included, or unsupported authentication method). */ export class InvalidClientError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_client", message, errorUri); - } + static errorCode = "invalid_client"; } /** @@ -57,9 +58,7 @@ export class InvalidClientError extends OAuthError { * authorization request, or was issued to another client. */ export class InvalidGrantError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_grant", message, errorUri); - } + static errorCode = "invalid_grant"; } /** @@ -67,9 +66,7 @@ export class InvalidGrantError extends OAuthError { * this authorization grant type. */ export class UnauthorizedClientError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("unauthorized_client", message, errorUri); - } + static errorCode = "unauthorized_client"; } /** @@ -77,9 +74,7 @@ export class UnauthorizedClientError extends OAuthError { * by the authorization server. */ export class UnsupportedGrantTypeError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("unsupported_grant_type", message, errorUri); - } + static errorCode = "unsupported_grant_type"; } /** @@ -87,18 +82,14 @@ export class UnsupportedGrantTypeError extends OAuthError { * exceeds the scope granted by the resource owner. */ export class InvalidScopeError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_scope", message, errorUri); - } + static errorCode = "invalid_scope"; } /** * Access denied error - The resource owner or authorization server denied the request. */ export class AccessDeniedError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("access_denied", message, errorUri); - } + static errorCode = "access_denied"; } /** @@ -106,9 +97,7 @@ export class AccessDeniedError extends OAuthError { * that prevented it from fulfilling the request. */ export class ServerError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("server_error", message, errorUri); - } + static errorCode = "server_error"; } /** @@ -116,9 +105,7 @@ export class ServerError extends OAuthError { * handle the request due to a temporary overloading or maintenance of the server. */ export class TemporarilyUnavailableError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("temporarily_unavailable", message, errorUri); - } + static errorCode = "temporarily_unavailable"; } /** @@ -126,9 +113,7 @@ export class TemporarilyUnavailableError extends OAuthError { * obtaining an authorization code using this method. */ export class UnsupportedResponseTypeError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("unsupported_response_type", message, errorUri); - } + static errorCode = "unsupported_response_type"; } /** @@ -136,9 +121,7 @@ export class UnsupportedResponseTypeError extends OAuthError { * the requested token type. */ export class UnsupportedTokenTypeError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("unsupported_token_type", message, errorUri); - } + static errorCode = "unsupported_token_type"; } /** @@ -146,9 +129,7 @@ export class UnsupportedTokenTypeError extends OAuthError { * or invalid for other reasons. */ export class InvalidTokenError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_token", message, errorUri); - } + static errorCode = "invalid_token"; } /** @@ -156,9 +137,7 @@ export class InvalidTokenError extends OAuthError { * (Custom, non-standard error) */ export class MethodNotAllowedError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("method_not_allowed", message, errorUri); - } + static errorCode = "method_not_allowed"; } /** @@ -166,9 +145,7 @@ export class MethodNotAllowedError extends OAuthError { * (Custom, non-standard error based on RFC 6585) */ export class TooManyRequestsError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("too_many_requests", message, errorUri); - } + static errorCode = "too_many_requests"; } /** @@ -176,16 +153,47 @@ export class TooManyRequestsError extends OAuthError { * (Custom error for dynamic client registration - RFC 7591) */ export class InvalidClientMetadataError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_client_metadata", message, errorUri); - } + static errorCode = "invalid_client_metadata"; } /** * Insufficient scope error - The request requires higher privileges than provided by the access token. */ export class InsufficientScopeError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("insufficient_scope", message, errorUri); + static errorCode = "insufficient_scope"; +} + +/** + * A utility class for defining one-off error codes + */ +export class CustomOAuthError extends OAuthError { + constructor(private readonly customErrorCode: string, message: string, errorUri?: string) { + super(message, errorUri); + } + + get errorCode(): string { + return this.customErrorCode; } } + +/** + * A full list of all OAuthErrors, enabling parsing from error responses + */ +export const OAUTH_ERRORS = { + [InvalidRequestError.errorCode]: InvalidRequestError, + [InvalidClientError.errorCode]: InvalidClientError, + [InvalidGrantError.errorCode]: InvalidGrantError, + [UnauthorizedClientError.errorCode]: UnauthorizedClientError, + [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, + [InvalidScopeError.errorCode]: InvalidScopeError, + [AccessDeniedError.errorCode]: AccessDeniedError, + [ServerError.errorCode]: ServerError, + [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError, + [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError, + [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError, + [InvalidTokenError.errorCode]: InvalidTokenError, + [MethodNotAllowedError.errorCode]: MethodNotAllowedError, + [TooManyRequestsError.errorCode]: TooManyRequestsError, + [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, + [InsufficientScopeError.errorCode]: InsufficientScopeError, +} as const; diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 9b051b1af..38639b1de 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; +import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; // Mock verifier @@ -305,7 +305,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer valid-token", }; - mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); + mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError("custom_error", "Some OAuth error")); const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); From f3584f2a4162febadf39300d7f37be63a91ce05d Mon Sep 17 00:00:00 2001 From: Jonathan Wang <229140959@qq.com> Date: Tue, 15 Jul 2025 00:24:10 +0800 Subject: [PATCH 167/208] fix: use authorization_server_url as issuer when fetching metadata (#763) --- src/client/auth.test.ts | 6 +++--- src/client/auth.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index c5021def7..ce0cc7081 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1778,7 +1778,7 @@ describe("OAuth Authorization", () => { status: 200, json: async () => ({ resource: "https://my.resource.com/", - authorization_servers: ["https://auth.example.com/"], + authorization_servers: ["https://auth.example.com/oauth"], }), }); } else if (urlString === "https://auth.example.com/.well-known/oauth-authorization-server/path/name") { @@ -1821,8 +1821,8 @@ describe("OAuth Authorization", () => { // First call should be to PRM expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name"); - // Second call should be to AS metadata with the path from serverUrl - expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + // Second call should be to AS metadata with the path from authorization server + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth"); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 47400768f..4a8bbe2d2 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -606,7 +606,7 @@ export async function discoverOAuthMetadata( protocolVersion ??= LATEST_PROTOCOL_VERSION; const response = await discoverMetadataWithFallback( - issuer, + authorizationServerUrl, 'oauth-authorization-server', { protocolVersion, From af61a086ae4346a844e4359679404e42361e724d Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 15 Jul 2025 02:40:17 -0600 Subject: [PATCH 168/208] feat(protocol): Debounce notifications to improve network efficiancy (#746) --- README.md | 37 ++++++++ src/shared/protocol.test.ts | 183 ++++++++++++++++++++++++++++++++++++ src/shared/protocol.ts | 49 ++++++++++ 3 files changed, 269 insertions(+) diff --git a/README.md b/README.md index ec1788f18..4684c67c7 100644 --- a/README.md +++ b/README.md @@ -913,6 +913,43 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Improving Network Efficiency with Notification Debouncing + +When performing bulk updates that trigger notifications (e.g., enabling or disabling multiple tools in a loop), the SDK can send a large number of messages in a short period. To improve performance and reduce network traffic, you can enable notification debouncing. + +This feature coalesces multiple, rapid calls for the same notification type into a single message. For example, if you disable five tools in a row, only one `notifications/tools/list_changed` message will be sent instead of five. + +> [!IMPORTANT] +> This feature is designed for "simple" notifications that do not carry unique data in their parameters. To prevent silent data loss, debouncing is **automatically bypassed** for any notification that contains a `params` object or a `relatedRequestId`. Such notifications will always be sent immediately. + +This is an opt-in feature configured during server initialization. + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const server = new McpServer( + { + name: "efficient-server", + version: "1.0.0" + }, + { + // Enable notification debouncing for specific methods + debouncedNotificationMethods: [ + 'notifications/tools/list_changed', + 'notifications/resources/list_changed', + 'notifications/prompts/list_changed' + ] + } +); + +// Now, any rapid changes to tools, resources, or prompts will result +// in a single, consolidated notification for each type. +server.registerTool("tool1", ...).disable(); +server.registerTool("tool2", ...).disable(); +server.registerTool("tool3", ...).disable(); +// Only one 'notifications/tools/list_changed' is sent. +``` + ### Low-Level Server For more control, you can use the low-level Server class directly: diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index b16db73f3..f4e74c8bb 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -466,6 +466,189 @@ describe("protocol tests", () => { await expect(requestPromise).resolves.toEqual({ result: "success" }); }); }); + + describe("Debounced Notifications", () => { + // We need to flush the microtask queue to test the debouncing logic. + // This helper function does that. + const flushMicrotasks = () => new Promise(resolve => setImmediate(resolve)); + + it("should NOT debounce a notification that has parameters", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced_with_params'] }); + await protocol.connect(transport); + + // ACT + // These notifications are configured for debouncing but contain params, so they should be sent immediately. + await protocol.notification({ method: 'test/debounced_with_params', params: { data: 1 } }); + await protocol.notification({ method: 'test/debounced_with_params', params: { data: 2 } }); + + // ASSERT + // Both should have been sent immediately to avoid data loss. + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 1 } }), undefined); + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 2 } }), undefined); + }); + + it("should NOT debounce a notification that has a relatedRequestId", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced_with_options'] }); + await protocol.connect(transport); + + // ACT + await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-1' }); + await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-2' }); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-1' }); + expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' }); + }); + + it("should clear pending debounced notifications on connection close", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + // Schedule a notification but don't flush the microtask queue. + protocol.notification({ method: 'test/debounced' }); + + // Close the connection. This should clear the pending set. + await protocol.close(); + + // Now, flush the microtask queue. + await flushMicrotasks(); + + // ASSERT + // The send should never have happened because the transport was cleared. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it("should debounce multiple synchronous calls when params property is omitted", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + // This is the more idiomatic way to write a notification with no params. + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + + expect(sendSpy).not.toHaveBeenCalled(); + await flushMicrotasks(); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(1); + // The final sent object might not even have the `params` key, which is fine. + // We can check that it was called and that the params are "falsy". + const sentNotification = sendSpy.mock.calls[0][0]; + expect(sentNotification.method).toBe('test/debounced'); + expect(sentNotification.params).toBeUndefined(); + }); + + it("should debounce calls when params is explicitly undefined", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + protocol.notification({ method: 'test/debounced', params: undefined }); + protocol.notification({ method: 'test/debounced', params: undefined }); + await flushMicrotasks(); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'test/debounced', + params: undefined + }), + undefined + ); + }); + + it("should send non-debounced notifications immediately and multiple times", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced'] }); // Configure for a different method + await protocol.connect(transport); + + // ACT + // Call a non-debounced notification method multiple times. + await protocol.notification({ method: 'test/immediate' }); + await protocol.notification({ method: 'test/immediate' }); + + // ASSERT + // Since this method is not in the debounce list, it should be sent every time. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it("should not debounce any notifications if the option is not provided", async () => { + // ARRANGE + // Use the default protocol from beforeEach, which has no debounce options. + await protocol.connect(transport); + + // ACT + await protocol.notification({ method: 'any/method' }); + await protocol.notification({ method: 'any/method' }); + + // ASSERT + // Without the config, behavior should be immediate sending. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it("should handle sequential batches of debounced notifications correctly", async () => { + // ARRANGE + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT (Batch 1) + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + await flushMicrotasks(); + + // ASSERT (Batch 1) + expect(sendSpy).toHaveBeenCalledTimes(1); + + // ACT (Batch 2) + // After the first batch has been sent, a new batch should be possible. + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + await flushMicrotasks(); + + // ASSERT (Batch 2) + // The total number of sends should now be 2. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + }); }); describe("mergeCapabilities", () => { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 50bdcc3ca..6142140dd 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -45,6 +45,13 @@ export type ProtocolOptions = { * Currently this defaults to false, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to true. */ enforceStrictCapabilities?: boolean; + /** + * An array of notification method names that should be automatically debounced. + * Any notifications with a method in this list will be coalesced if they + * occur in the same tick of the event loop. + * e.g., ['notifications/tools/list_changed'] + */ + debouncedNotificationMethods?: string[]; }; /** @@ -191,6 +198,7 @@ export abstract class Protocol< > = new Map(); private _progressHandlers: Map = new Map(); private _timeoutInfo: Map = new Map(); + private _pendingDebouncedNotifications = new Set(); /** * Callback for when the connection is closed for any reason. @@ -321,6 +329,7 @@ export abstract class Protocol< const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); + this._pendingDebouncedNotifications.clear(); this._transport = undefined; this.onclose?.(); @@ -632,6 +641,46 @@ export abstract class Protocol< this.assertNotificationCapability(notification.method); + const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; + // A notification can only be debounced if it's in the list AND it's "simple" + // (i.e., has no parameters and no related request ID that could be lost). + const canDebounce = debouncedMethods.includes(notification.method) + && !notification.params + && !(options?.relatedRequestId); + + if (canDebounce) { + // If a notification of this type is already scheduled, do nothing. + if (this._pendingDebouncedNotifications.has(notification.method)) { + return; + } + + // Mark this notification type as pending. + this._pendingDebouncedNotifications.add(notification.method); + + // Schedule the actual send to happen in the next microtask. + // This allows all synchronous calls in the current event loop tick to be coalesced. + Promise.resolve().then(() => { + // Un-mark the notification so the next one can be scheduled. + this._pendingDebouncedNotifications.delete(notification.method); + + // SAFETY CHECK: If the connection was closed while this was pending, abort. + if (!this._transport) { + return; + } + + const jsonrpcNotification: JSONRPCNotification = { + ...notification, + jsonrpc: "2.0", + }; + // Send the notification, but don't await it here to avoid blocking. + // Handle potential errors with a .catch(). + this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)); + }); + + // Return immediately. + return; + } + const jsonrpcNotification: JSONRPCNotification = { ...notification, jsonrpc: "2.0", From 16ea27783cc534f629ad548ccaa8a1af16832763 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 15 Jul 2025 10:43:02 -0600 Subject: [PATCH 169/208] fix(731): StreamableHTTPClientTransport Fails to Reconnect on Non-Resumable Streams (#732) --- src/client/streamableHttp.test.ts | 107 +++++++++++++++++++++++++++++- src/client/streamableHttp.ts | 36 +++++----- 2 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 218669f7b..c54cf2896 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,6 +1,6 @@ import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js"; import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { JSONRPCMessage } from "../types.js"; +import { JSONRPCMessage, JSONRPCRequest } from "../types.js"; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js"; @@ -594,6 +594,111 @@ describe("StreamableHTTPClientTransport", () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); + + describe('Reconnection Logic', () => { + let transport: StreamableHTTPClientTransport; + + // Use fake timers to control setTimeout and make the test instant. + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should reconnect a GET-initiated notification stream that fails', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity + } + }); + + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + + const failingStream = new ReadableStream({ + start(controller) { controller.error(new Error("Network failure")); } + }); + + const fetchMock = global.fetch as jest.Mock; + // Mock the initial GET request, which will fail. + fetchMock.mockResolvedValueOnce({ + ok: true, status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: failingStream, + }); + // Mock the reconnection GET request, which will succeed. + fetchMock.mockResolvedValueOnce({ + ok: true, status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: new ReadableStream(), + }); + + // ACT + await transport.start(); + // Trigger the GET stream directly using the internal method for a clean test. + await transport["_startOrAuthSse"]({}); + await jest.advanceTimersByTimeAsync(20); // Trigger reconnection timeout + + // ASSERT + expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), + })); + // THE KEY ASSERTION: A second fetch call proves reconnection was attempted. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1]?.method).toBe('GET'); + expect(fetchMock.mock.calls[1][1]?.method).toBe('GET'); + }); + + it('should NOT reconnect a POST-initiated stream that fails', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity + } + }); + + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + + const failingStream = new ReadableStream({ + start(controller) { controller.error(new Error("Network failure")); } + }); + + const fetchMock = global.fetch as jest.Mock; + // Mock the POST request. It returns a streaming content-type but a failing body. + fetchMock.mockResolvedValueOnce({ + ok: true, status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: failingStream, + }); + + // A dummy request message to trigger the `send` logic. + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'long_running_tool', + id: 'request-1', + params: {}, + }; + + // ACT + await transport.start(); + // Use the public `send` method to initiate a POST that gets a stream response. + await transport.send(requestMessage); + await jest.advanceTimersByTimeAsync(20); // Advance time to check for reconnections + + // ASSERT + expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), + })); + // THE KEY ASSERTION: Fetch was only called ONCE. No reconnection was attempted. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + }); + }); it("invalidates all credentials on InvalidClientError during auth", async () => { const message: JSONRPCMessage = { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index b81f1a5d8..b0894fce1 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -231,7 +231,7 @@ const response = await (this._fetch ?? fetch)(this._url, { ); } - this._handleSseStream(response.body, options); + this._handleSseStream(response.body, options, true); } catch (error) { this.onerror?.(error as Error); throw error; @@ -300,7 +300,11 @@ const response = await (this._fetch ?? fetch)(this._url, { }, delay); } - private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions): void { + private _handleSseStream( + stream: ReadableStream | null, + options: StartSSEOptions, + isReconnectable: boolean, + ): void { if (!stream) { return; } @@ -347,20 +351,22 @@ const response = await (this._fetch ?? fetch)(this._url, { this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing - if (this._abortController && !this._abortController.signal.aborted) { + if ( + isReconnectable && + this._abortController && + !this._abortController.signal.aborted + ) { // Use the exponential backoff reconnection strategy - if (lastEventId !== undefined) { - try { - this._scheduleReconnection({ - resumptionToken: lastEventId, - onresumptiontoken, - replayMessageId - }, 0); - } - catch (error) { - this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + try { + this._scheduleReconnection({ + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, 0); + } + catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); - } } } } @@ -473,7 +479,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onresumptiontoken }); + this._handleSseStream(response.body, { onresumptiontoken }, false); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); From 9d2a0aeb7b9be46cef91c0c9140594128b59e956 Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Thu, 17 Jul 2025 09:57:54 -0700 Subject: [PATCH 170/208] fix: consistently use consumer-provided fetch function (#767) --- src/client/auth.test.ts | 164 ++++++++++ src/client/auth.ts | 51 +++- src/client/sse.test.ts | 335 ++++++++++++++++++++- src/client/sse.ts | 8 +- src/client/streamableHttp.test.ts | 176 +++++++++-- src/client/streamableHttp.ts | 6 +- src/server/auth/providers/proxyProvider.ts | 15 +- 7 files changed, 709 insertions(+), 46 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index ce0cc7081..b0ea8d1e8 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -347,6 +347,35 @@ describe("OAuth Authorization", () => { const [url] = calls[0]; expect(url.toString()).toBe("https://custom.example.com/metadata"); }); + + it("supports overriding the fetch function used for requests", async () => { + const validMetadata = { + resource: "https://resource.example.com", + authorization_servers: ["https://auth.example.com"], + }; + + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + "https://resource.example.com", + undefined, + customFetch + ); + + expect(metadata).toEqual(validMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]; + expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); }); describe("discoverOAuthMetadata", () => { @@ -619,6 +648,39 @@ describe("OAuth Authorization", () => { discoverOAuthMetadata("https://auth.example.com") ).rejects.toThrow(); }); + + it("supports overriding the fetch function used for requests", async () => { + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; + + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata( + "https://auth.example.com", + {}, + customFetch + ); + + expect(metadata).toEqual(validMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); }); describe("startAuthorization", () => { @@ -917,6 +979,46 @@ describe("OAuth Authorization", () => { }) ).rejects.toThrow("Token exchange failed"); }); + + it("supports overriding the fetch function used for requests", async () => { + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), + fetchFn: customFetch, + }); + + expect(tokens).toEqual(validTokens); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]; + expect(url.toString()).toBe("https://auth.example.com/token"); + expect(options).toEqual( + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + body: expect.any(URLSearchParams), + }) + ); + + const body = options.body as URLSearchParams; + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("code123"); + expect(body.get("code_verifier")).toBe("verifier123"); + expect(body.get("client_id")).toBe("client123"); + expect(body.get("client_secret")).toBe("secret123"); + expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); }); describe("refreshAuthorization", () => { @@ -1824,6 +1926,68 @@ describe("OAuth Authorization", () => { // Second call should be to AS metadata with the path from authorization server expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth"); }); + + it("supports overriding the fetch function used for requests", async () => { + const customFetch = jest.fn(); + + // Mock PRM discovery + customFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://resource.example.com", + authorization_servers: ["https://auth.example.com"], + }), + }); + + // Mock AS metadata discovery + customFetch.mockResolvedValueOnce({ + 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", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { + return { + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }; + }, + clientInformation: jest.fn().mockResolvedValue({ + client_id: "client123", + client_secret: "secret123", + }), + tokens: jest.fn().mockResolvedValue(undefined), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn().mockResolvedValue("verifier123"), + }; + + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + fetchFn: customFetch, + }); + + expect(result).toBe("REDIRECT"); + expect(customFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).not.toHaveBeenCalled(); + + // Verify custom fetch was called for PRM discovery + expect(customFetch.mock.calls[0][0].toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); + + // Verify custom fetch was called for AS metadata discovery + expect(customFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); }); describe("exchangeAuthorization with multiple client authentication methods", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 4a8bbe2d2..b5a3a6a43 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -19,6 +19,7 @@ import { ServerError, UnauthorizedClientError } from "../server/auth/errors.js"; +import { FetchLike } from "../shared/transport.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -281,8 +282,9 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { - + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; +}): Promise { try { return await authInternal(provider, options); } catch (error) { @@ -305,18 +307,21 @@ async function authInternal( { serverUrl, authorizationCode, scope, - resourceMetadataUrl + resourceMetadataUrl, + fetchFn, }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL - }): Promise { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + }, +): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -328,7 +333,7 @@ async function authInternal( const metadata = await discoverOAuthMetadata(serverUrl, { authorizationServerUrl - }); + }, fetchFn); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -361,6 +366,7 @@ async function authInternal( redirectUri: provider.redirectUrl, resource, addClientAuthentication: provider.addClientAuthentication, + fetchFn: fetchFn, }); await provider.saveTokens(tokens); @@ -469,10 +475,12 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, + fetchFn: FetchLike = fetch, ): Promise { const response = await discoverMetadataWithFallback( serverUrl, 'oauth-protected-resource', + fetchFn, { protocolVersion: opts?.protocolVersion, metadataUrl: opts?.resourceMetadataUrl, @@ -497,14 +505,15 @@ export async function discoverOAuthProtectedResourceMetadata( async function fetchWithCorsRetry( url: URL, headers?: Record, + fetchFn: FetchLike = fetch, ): Promise { try { - return await fetch(url, { headers }); + return await fetchFn(url, { headers }); } catch (error) { if (error instanceof TypeError) { if (headers) { // CORS errors come back as TypeError, retry without headers - return fetchWithCorsRetry(url) + return fetchWithCorsRetry(url, undefined, fetchFn) } else { // We're getting CORS errors on retry too, return undefined return undefined @@ -532,11 +541,12 @@ function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string { async function tryMetadataDiscovery( url: URL, protocolVersion: string, + fetchFn: FetchLike = fetch, ): Promise { const headers = { "MCP-Protocol-Version": protocolVersion }; - return await fetchWithCorsRetry(url, headers); + return await fetchWithCorsRetry(url, headers, fetchFn); } /** @@ -552,6 +562,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + fetchFn: FetchLike, opts?: { protocolVersion?: string; metadataUrl?: string | URL, metadataServerUrl?: string | URL }, ): Promise { const issuer = new URL(serverUrl); @@ -567,12 +578,12 @@ async function discoverMetadataWithFallback( url.search = issuer.search; } - let response = await tryMetadataDiscovery(url, protocolVersion); + let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion); + response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); } return response; @@ -593,6 +604,7 @@ export async function discoverOAuthMetadata( authorizationServerUrl?: string | URL, protocolVersion?: string, } = {}, + fetchFn: FetchLike = fetch, ): Promise { if (typeof issuer === 'string') { issuer = new URL(issuer); @@ -608,6 +620,7 @@ export async function discoverOAuthMetadata( const response = await discoverMetadataWithFallback( authorizationServerUrl, 'oauth-authorization-server', + fetchFn, { protocolVersion, metadataServerUrl: authorizationServerUrl, @@ -730,7 +743,8 @@ export async function exchangeAuthorization( codeVerifier, redirectUri, resource, - addClientAuthentication + addClientAuthentication, + fetchFn, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; @@ -739,6 +753,7 @@ export async function exchangeAuthorization( redirectUri: string | URL; resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; + fetchFn?: FetchLike; }, ): Promise { const grantType = "authorization_code"; @@ -781,7 +796,7 @@ export async function exchangeAuthorization( params.set("resource", resource.href); } - const response = await fetch(tokenUrl, { + const response = await (fetchFn ?? fetch)(tokenUrl, { method: "POST", headers, body: params, @@ -814,12 +829,14 @@ export async function refreshAuthorization( refreshToken, resource, addClientAuthentication, + fetchFn, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; + fetchFn?: FetchLike; } ): Promise { const grantType = "refresh_token"; @@ -863,7 +880,7 @@ export async function refreshAuthorization( params.set("resource", resource.href); } - const response = await fetch(tokenUrl, { + const response = await (fetchFn ?? fetch)(tokenUrl, { method: "POST", headers, body: params, @@ -883,9 +900,11 @@ export async function registerClient( { metadata, clientMetadata, + fetchFn, }: { metadata?: OAuthMetadata; clientMetadata: OAuthClientMetadata; + fetchFn?: FetchLike; }, ): Promise { let registrationUrl: URL; @@ -900,7 +919,7 @@ export async function registerClient( registrationUrl = new URL("/register", authorizationServerUrl); } - const response = await fetch(registrationUrl, { + const response = await (fetchFn ?? fetch)(registrationUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 2cc4a1dd7..24bfe094c 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -1,4 +1,4 @@ -import { createServer, type IncomingMessage, type Server } from "http"; +import { createServer, ServerResponse, type IncomingMessage, type Server } from "http"; import { AddressInfo } from "net"; import { JSONRPCMessage } from "../types.js"; import { SSEClientTransport } from "./sse.js"; @@ -1108,4 +1108,337 @@ describe("SSEClientTransport", () => { expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); }); + + describe("custom fetch in auth code paths", () => { + let customFetch: jest.MockedFunction; + let globalFetchSpy: jest.SpyInstance; + let mockAuthProvider: jest.Mocked; + let resourceServerHandler: jest.Mock & { + req: IncomingMessage; + }], void>; + + /** + * Helper function to create a mock auth provider with configurable behavior + */ + const createMockAuthProvider = (config: { + hasTokens?: boolean; + tokensExpired?: boolean; + hasRefreshToken?: boolean; + clientRegistered?: boolean; + authorizationCode?: string; + } = {}): jest.Mocked => { + const tokens = config.hasTokens ? { + access_token: config.tokensExpired ? "expired-token" : "valid-token", + token_type: "Bearer" as const, + ...(config.hasRefreshToken && { refresh_token: "refresh-token" }) + } : undefined; + + const clientInfo = config.clientRegistered ? { + client_id: "test-client-id", + client_secret: "test-client-secret" + } : undefined; + + return { + get redirectUrl() { return "http://localhost/callback"; }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost/callback"], + client_name: "Test Client" + }; + }, + clientInformation: jest.fn().mockResolvedValue(clientInfo), + tokens: jest.fn().mockResolvedValue(tokens), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn().mockResolvedValue("test-verifier"), + invalidateCredentials: jest.fn(), + }; + }; + + const createCustomFetchMockAuthServer = async () => { + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + issuer: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}`, + authorization_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/authorize`, + token_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/token`, + registration_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/register`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + })); + return; + } + + if (req.url === "/token" && req.method === "POST") { + // Handle token exchange request + let body = ""; + req.on("data", chunk => { body += chunk; }); + req.on("end", () => { + const params = new URLSearchParams(body); + if (params.get("grant_type") === "authorization_code" && + params.get("code") === "test-auth-code" && + params.get("client_id") === "test-client-id") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token" + })); + } else { + res.writeHead(400).end(); + } + }); + return; + } + + res.writeHead(404).end(); + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, "127.0.0.1", () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + }; + + const createCustomFetchMockResourceServer = async () => { + // Set up resource server that provides OAuth metadata + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [authBaseUrl.href], + })); + return; + } + + resourceServerHandler(req, res); + }); + + // Start resource server on random port + await new Promise(resolve => { + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + }; + + beforeEach(async () => { + // Close existing servers to set up custom auth flow servers + resourceServer.close(); + authServer.close(); + + const originalFetch = fetch; + + // Create custom fetch spy that delegates to real fetch + customFetch = jest.fn((url, init) => { + return originalFetch(url.toString(), init); + }); + + // Spy on global fetch to detect unauthorized usage + globalFetchSpy = jest.spyOn(global, 'fetch'); + + // Create mock auth provider with default configuration + mockAuthProvider = createMockAuthProvider({ + hasTokens: false, + clientRegistered: true + }); + + // Set up auth server that handles OAuth discovery and token requests + await createCustomFetchMockAuthServer(); + + // Set up resource server + resourceServerHandler = jest.fn((_req: IncomingMessage, res: ServerResponse & { + req: IncomingMessage; + }) => { + res.writeHead(404).end(); + }); + await createCustomFetchMockResourceServer(); + }); + + afterEach(() => { + globalFetchSpy.mockRestore(); + }); + + it("uses custom fetch during auth flow on SSE connection 401 - no global fetch fallback", async () => { + // Set up resource server that returns 401 on SSE connection and provides OAuth metadata + resourceServerHandler.mockImplementation((req, res) => { + if (req.url === "/") { + // Return 401 to trigger auth flow + res.writeHead(401, { + "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` + }); + res.end(); + return; + } + + res.writeHead(404).end(); + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + fetch: customFetch, + }); + + // Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError + await expect(transport.start()).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + + it("uses custom fetch during auth flow on POST request 401 - no global fetch fallback", async () => { + // Set up resource server that accepts SSE connection but returns 401 on POST + resourceServerHandler.mockImplementation((req, res) => { + switch (req.method) { + case "GET": + if (req.url === "/") { + // Accept SSE connection + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write("event: endpoint\n"); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + return; + } + break; + + case "POST": + if (req.url === "/") { + // Return 401 to trigger auth retry + res.writeHead(401, { + "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` + }); + res.end(); + return; + } + break; + } + + res.writeHead(404).end(); + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + fetch: customFetch, + }); + + // Start the transport (should succeed) + await transport.start(); + + // Send a message that should trigger 401 and auth retry + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "1", + method: "test", + params: {}, + }; + + // Attempt to send message - should trigger auth flow and eventually fail + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have attempted the POST request that triggered the 401 + const postCalls = customFetchCalls.filter(([url, options]) => + url.toString() === resourceBaseUrl.href && options?.method === "POST" + ); + expect(postCalls.length).toBeGreaterThan(0); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + + it("uses custom fetch in finishAuth method - no global fetch fallback", async () => { + // Create mock auth provider that expects to save tokens + const authProviderWithCode = createMockAuthProvider({ + clientRegistered: true, + authorizationCode: "test-auth-code" + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: authProviderWithCode, + fetch: customFetch, + }); + + // Call finishAuth with authorization code + await transport.finishAuth("test-auth-code"); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have called token endpoint for authorization code exchange + const tokenCalls = customFetchCalls.filter(([url, options]) => + url.toString().includes('/token') && options?.method === "POST" + ); + expect(tokenCalls.length).toBeGreaterThan(0); + + // Verify tokens were saved + expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token" + }); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 568a51592..e1c86ccdb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -93,7 +93,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -218,7 +218,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -246,13 +246,13 @@ export class SSEClientTransport implements Transport { signal: this._abortController?.signal, }; -const response = await (this._fetch ?? fetch)(this._endpoint, init); + const response = await (this._fetch ?? fetch)(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index c54cf2896..88fd48017 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,4 +1,4 @@ -import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions, StartSSEOptions } from "./streamableHttp.js"; +import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { JSONRPCMessage, JSONRPCRequest } from "../types.js"; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from "../server/auth/errors.js"; @@ -445,36 +445,31 @@ describe("StreamableHTTPClientTransport", () => { expect(errorSpy).toHaveBeenCalled(); }); - it("uses custom fetch implementation", async () => { - const authToken = "Bearer custom-token"; - - const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => { - const headers = new Headers(init?.headers); - headers.set("Authorization", authToken); - return (global.fetch as jest.Mock)(url, { ...init, headers }); - }); - - (global.fetch as jest.Mock) + it("uses custom fetch implementation if provided", async () => { + // Create custom fetch + const customFetch = jest.fn() .mockResolvedValueOnce( new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }) ) .mockResolvedValueOnce(new Response(null, { status: 202 })); - transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { fetch: fetchWithAuth }); + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + fetch: customFetch + }); await transport.start(); await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}); await transport.send({ jsonrpc: "2.0", method: "test", params: {}, id: "1" } as JSONRPCMessage); - expect(fetchWithAuth).toHaveBeenCalled(); - for (const call of (global.fetch as jest.Mock).mock.calls) { - const headers = call[1].headers as Headers; - expect(headers.get("Authorization")).toBe(authToken); - } + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(global.fetch).not.toHaveBeenCalled(); }); - it("should always send specified custom headers", async () => { const requestInit = { headers: { @@ -855,4 +850,149 @@ describe("StreamableHTTPClientTransport", () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); + + describe("custom fetch in auth code paths", () => { + it("uses custom fetch during auth flow on 401 - no global fetch fallback", async () => { + const unauthedResponse = { + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }; + + // Create custom fetch + const customFetch = jest.fn() + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "http://localhost:1234", + authorization_endpoint: "http://localhost:1234/authorize", + token_endpoint: "http://localhost:1234/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }) + // Token refresh fails with InvalidClientError + .mockResolvedValueOnce(Response.json( + new InvalidClientError("Client authentication failed").toResponseObject(), + { status: 400 } + )) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError + await transport.start(); + await expect((transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({})).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("uses custom fetch in finishAuth method - no global fetch fallback", async () => { + // Create custom fetch + const customFetch = jest.fn() + // Protected resource metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + authorization_servers: ["http://localhost:1234"], + resource: "http://localhost:1234/mcp" + }), + }) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "http://localhost:1234", + authorization_endpoint: "http://localhost:1234/authorize", + token_endpoint: "http://localhost:1234/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }) + // Code exchange + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }), + }); + + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Call finishAuth with authorization code + await transport.finishAuth("test-auth-code"); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have called token endpoint for authorization code exchange + const tokenCalls = customFetchCalls.filter(([url, options]) => + url.toString().includes('/token') && options?.method === "POST" + ); + expect(tokenCalls.length).toBeGreaterThan(0); + + // Verify tokens were saved + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token" + }); + + // Global fetch should never have been called + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index b0894fce1..77a15c923 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -156,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -392,7 +392,7 @@ const response = await (this._fetch ?? fetch)(this._url, { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -440,7 +440,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index de74862b5..c66a8707c 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -10,6 +10,7 @@ import { import { AuthInfo } from "../types.js"; import { AuthorizationParams, OAuthServerProvider } from "../provider.js"; import { ServerError } from "../errors.js"; +import { FetchLike } from "../../../shared/transport.js"; export type ProxyEndpoints = { authorizationUrl: string; @@ -34,6 +35,10 @@ export type ProxyOptions = { */ getClient: (clientId: string) => Promise; + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; }; /** @@ -43,6 +48,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { protected readonly _endpoints: ProxyEndpoints; protected readonly _verifyAccessToken: (token: string) => Promise; protected readonly _getClient: (clientId: string) => Promise; + protected readonly _fetch?: FetchLike; skipLocalPkceValidation = true; @@ -55,6 +61,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { this._endpoints = options.endpoints; this._verifyAccessToken = options.verifyAccessToken; this._getClient = options.getClient; + this._fetch = options.fetch; if (options.endpoints?.revocationUrl) { this.revokeToken = async ( client: OAuthClientInformationFull, @@ -76,7 +83,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("token_type_hint", request.token_type_hint); } - const response = await fetch(revocationUrl, { + const response = await (this._fetch ?? fetch)(revocationUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -97,7 +104,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { getClient: this._getClient, ...(registrationUrl && { registerClient: async (client: OAuthClientInformationFull) => { - const response = await fetch(registrationUrl, { + const response = await (this._fetch ?? fetch)(registrationUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -178,7 +185,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("resource", resource.href); } - const response = await fetch(this._endpoints.tokenUrl, { + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -220,7 +227,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("resource", resource.href); } - const response = await fetch(this._endpoints.tokenUrl, { + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", From 2db0dbebe7c0ff6af5ca392c25032841aa48e06e Mon Sep 17 00:00:00 2001 From: Christian Daguerre Date: Thu, 17 Jul 2025 19:51:51 +0200 Subject: [PATCH 171/208] fix client id issuance date should only be sent when generated (#775) --- src/server/auth/clients.ts | 2 +- src/server/auth/handlers/register.test.ts | 1 + src/server/auth/handlers/register.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/auth/clients.ts b/src/server/auth/clients.ts index 3b9110d5e..8bbc6ac4d 100644 --- a/src/server/auth/clients.ts +++ b/src/server/auth/clients.ts @@ -16,5 +16,5 @@ export interface OAuthRegisteredClientsStore { * * If unimplemented, dynamic client registration is unsupported. */ - registerClient?(client: Omit): OAuthClientInformationFull | Promise; + registerClient?(client: Omit): OAuthClientInformationFull | Promise; } \ No newline at end of file diff --git a/src/server/auth/handlers/register.test.ts b/src/server/auth/handlers/register.test.ts index d95e6d827..1c3f16cb0 100644 --- a/src/server/auth/handlers/register.test.ts +++ b/src/server/auth/handlers/register.test.ts @@ -236,6 +236,7 @@ describe('Client Registration Handler', () => { expect(response.status).toBe(201); expect(response.body.client_id).toBeUndefined(); + expect(response.body.client_id_issued_at).toBeUndefined(); }); it('handles client with all metadata fields', async () => { diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 197e00533..4d8bea1ac 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -99,12 +99,12 @@ export function clientRegistrationHandler({ let clientInfo: Omit & { client_id?: string } = { ...clientMetadata, client_secret: clientSecret, - client_id_issued_at: clientIdIssuedAt, client_secret_expires_at: clientSecretExpiresAt, }; if (clientIdGeneration) { clientInfo.client_id = crypto.randomUUID(); + clientInfo.client_id_issued_at = clientIdIssuedAt; } clientInfo = await clientsStore.registerClient!(clientInfo); From 400b020c854d31112c8f29a2e280072731ed3d5f Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 17 Jul 2025 20:18:12 +0100 Subject: [PATCH 172/208] 1.16.0 (#779) --- 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 01bc09539..254a8e71d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.16.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 894081d7e..1bd2cea91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.15.1", + "version": "1.16.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 0d545176f9ba852c97a18a40037abff40cd086c2 Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Fri, 18 Jul 2025 14:11:21 +0100 Subject: [PATCH 173/208] Add CODEOWNERS file for dsk (#781) --- .github/CODEOWNERS | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..1c9b7a5ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# TypeScript SDK Code Owners + +# Default owners for everything in the repo +* @modelcontextprotocol/typescript-sdk-maintainers + +# Auth team owns all auth-related code +/src/server/auth/ @modelcontextprotocol/typescript-sdk-auth +/src/client/auth* @modelcontextprotocol/typescript-sdk-auth +/src/shared/auth* @modelcontextprotocol/typescript-sdk-auth +/src/examples/client/simpleOAuthClient.ts @modelcontextprotocol/typescript-sdk-auth +/src/examples/server/demoInMemoryOAuthProvider.ts @modelcontextprotocol/typescript-sdk-auth \ No newline at end of file From c7887c082cb55ddc55d77523df88654ec4120dbf Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Mon, 21 Jul 2025 04:57:40 -0400 Subject: [PATCH 174/208] Add more robust base64 check (#786) --- src/types.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/types.ts b/src/types.ts index b96ab0500..323e37389 100644 --- a/src/types.ts +++ b/src/types.ts @@ -458,11 +458,31 @@ export const TextResourceContentsSchema = ResourceContentsSchema.extend({ text: z.string(), }); + +/** + * A Zod schema for validating Base64 strings that is more performant and + * robust for very large inputs than the default regex-based check. It avoids + * stack overflows by using the native `atob` function for validation. + */ +const Base64Schema = z.string().refine( + (val) => { + try { + // atob throws a DOMException if the string contains characters + // that are not part of the Base64 character set. + atob(val); + return true; + } catch { + return false; + } + }, + { message: "Invalid Base64 string" }, +); + export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ /** * A base64-encoded string representing the binary data of the item. */ - blob: z.string().base64(), + blob: Base64Schema, }); /** @@ -718,7 +738,7 @@ export const ImageContentSchema = z /** * The base64-encoded image data. */ - data: z.string().base64(), + data: Base64Schema, /** * The MIME type of the image. Different providers may support different image types. */ @@ -741,7 +761,7 @@ export const AudioContentSchema = z /** * The base64-encoded audio data. */ - data: z.string().base64(), + data: Base64Schema, /** * The MIME type of the audio. Different providers may support different audio types. */ @@ -894,7 +914,7 @@ export const ToolSchema = BaseMetadataSchema.extend({ }) .passthrough(), /** - * An optional JSON Schema object defining the structure of the tool's output returned in + * An optional JSON Schema object defining the structure of the tool's output returned in * the structuredContent field of a CallToolResult. */ outputSchema: z.optional( From 8e15edca0af05e7eaeb38e0880669a16f749e0f5 Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Wed, 23 Jul 2025 19:10:23 +0100 Subject: [PATCH 175/208] update codeowners (#803) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1c9b7a5ca..596e6991d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,7 @@ # TypeScript SDK Code Owners # Default owners for everything in the repo -* @modelcontextprotocol/typescript-sdk-maintainers +* @modelcontextprotocol/typescript-sdk # Auth team owns all auth-related code /src/server/auth/ @modelcontextprotocol/typescript-sdk-auth From 83168250f56bd4c5582bc7d075746f8f90ac2de4 Mon Sep 17 00:00:00 2001 From: Mason Chen Date: Thu, 24 Jul 2025 20:49:47 +0800 Subject: [PATCH 176/208] Fix indent (#807) --- src/client/streamableHttp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 77a15c923..12714ea44 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -207,7 +207,7 @@ export class StreamableHTTPClientTransport implements Transport { headers.set("last-event-id", resumptionToken); } -const response = await (this._fetch ?? fetch)(this._url, { + const response = await (this._fetch ?? fetch)(this._url, { method: "GET", headers, signal: this._abortController?.signal, @@ -427,7 +427,7 @@ const response = await (this._fetch ?? fetch)(this._url, { signal: this._abortController?.signal, }; -const response = await (this._fetch ?? fetch)(this._url, init); + const response = await (this._fetch ?? fetch)(this._url, init); // Handle session ID received during initialization const sessionId = response.headers.get("mcp-session-id"); @@ -533,7 +533,7 @@ const response = await (this._fetch ?? fetch)(this._url, init); signal: this._abortController?.signal, }; -const response = await (this._fetch ?? fetch)(this._url, init); + const response = await (this._fetch ?? fetch)(this._url, init); // We specifically handle 405 as a valid response according to the spec, // meaning the server does not support explicit session termination From 62c608d8cef50de5b78a99b8db554f6dbc4b0b77 Mon Sep 17 00:00:00 2001 From: Jonathan Wang Date: Thu, 24 Jul 2025 20:50:54 +0800 Subject: [PATCH 177/208] fix: Explicitly declare accpet type to json when exchanging oauth token (#801) --- src/client/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index b5a3a6a43..278bc1b4c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -774,6 +774,7 @@ export async function exchangeAuthorization( // Exchange code for tokens const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", }); const params = new URLSearchParams({ grant_type: grantType, From bb7cccc3ba1b23ab911962a3b314d13c1db88d90 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 24 Jul 2025 21:11:48 +0800 Subject: [PATCH 178/208] feat: support oidc discovery in client sdk (#652) Co-authored-by: Paul Carleton Co-authored-by: Claude --- src/client/auth.test.ts | 270 ++++++++++++++++++++++++++++++++++++---- src/client/auth.ts | 183 ++++++++++++++++++++++++--- src/client/sse.test.ts | 11 +- src/shared/auth.ts | 69 +++++++++- 4 files changed, 486 insertions(+), 47 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b0ea8d1e8..c3049124e 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1,6 +1,8 @@ import { LATEST_PROTOCOL_VERSION } from '../types.js'; import { discoverOAuthMetadata, + discoverAuthorizationServerMetadata, + buildDiscoveryUrls, startAuthorization, exchangeAuthorization, refreshAuthorization, @@ -11,7 +13,7 @@ import { type OAuthClientProvider, } from "./auth.js"; import {ServerError} from "../server/auth/errors.js"; -import { OAuthMetadata } from '../shared/auth.js'; +import { AuthorizationServerMetadata } from '../shared/auth.js'; // Mock fetch globally const mockFetch = jest.fn(); @@ -216,7 +218,7 @@ describe("OAuth Authorization", () => { ok: false, status: 404, }); - + // Second call (root fallback) succeeds mockFetch.mockResolvedValueOnce({ ok: true, @@ -226,17 +228,17 @@ describe("OAuth Authorization", () => { const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource/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://resource.example.com/.well-known/oauth-protected-resource"); @@ -251,7 +253,7 @@ describe("OAuth Authorization", () => { ok: false, status: 404, }); - + // Second call (root fallback) also returns 404 mockFetch.mockResolvedValueOnce({ ok: false, @@ -260,7 +262,7 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); - + const calls = mockFetch.mock.calls; expect(calls.length).toBe(2); }); @@ -274,10 +276,10 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/")) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); - + const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - + const [url] = calls[0]; expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); }); @@ -291,10 +293,10 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); - + const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - + const [url] = calls[0]; expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); }); @@ -302,13 +304,13 @@ describe("OAuth Authorization", () => { 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, @@ -318,10 +320,10 @@ describe("OAuth Authorization", () => { const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource"); @@ -340,10 +342,10 @@ describe("OAuth Authorization", () => { await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", { resourceMetadataUrl: "https://custom.example.com/metadata" })).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); - + const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided - + const [url] = calls[0]; expect(url.toString()).toBe("https://custom.example.com/metadata"); }); @@ -683,6 +685,222 @@ describe("OAuth Authorization", () => { }); }); + describe("buildDiscoveryUrls", () => { + it("generates correct URLs for server without path", () => { + const urls = buildDiscoveryUrls("https://auth.example.com"); + + expect(urls).toHaveLength(2); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: "https://auth.example.com/.well-known/oauth-authorization-server", + type: "oauth" + }, + { + url: "https://auth.example.com/.well-known/openid-configuration", + type: "oidc" + } + ]); + }); + + it("generates correct URLs for server with path", () => { + const urls = buildDiscoveryUrls("https://auth.example.com/tenant1"); + + expect(urls).toHaveLength(4); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", + type: "oauth" + }, + { + url: "https://auth.example.com/.well-known/oauth-authorization-server", + type: "oauth" + }, + { + url: "https://auth.example.com/.well-known/openid-configuration/tenant1", + type: "oidc" + }, + { + url: "https://auth.example.com/tenant1/.well-known/openid-configuration", + type: "oidc" + } + ]); + }); + + it("handles URL object input", () => { + const urls = buildDiscoveryUrls(new URL("https://auth.example.com/tenant1")); + + expect(urls).toHaveLength(4); + expect(urls[0].url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"); + }); + }); + + describe("discoverAuthorizationServerMetadata", () => { + const validOAuthMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; + + const validOpenIdMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + jwks_uri: "https://auth.example.com/jwks", + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }; + + it("tries URLs in order and returns first successful metadata", async () => { + // First OAuth URL fails with 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second OAuth URL (root) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://auth.example.com/tenant1" + ); + + expect(metadata).toEqual(validOAuthMetadata); + + // Verify it tried the URLs in the correct order + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + expect(calls[0][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/tenant1"); + expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("throws error when OIDC provider does not support S256 PKCE", async () => { + // OAuth discovery fails + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // OpenID Connect discovery succeeds but without S256 support + const invalidOpenIdMetadata = { + ...validOpenIdMetadata, + code_challenge_methods_supported: ["plain"], // Missing S256 + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => invalidOpenIdMetadata, + }); + + await expect( + discoverAuthorizationServerMetadata( + "https://auth.example.com" + ) + ).rejects.toThrow("does not support S256 code challenge method required by MCP specification"); + }); + + it("continues on 4xx errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata("https://mcp.example.com"); + + expect(metadata).toEqual(validOpenIdMetadata); + + }); + + it("throws on non-4xx errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + discoverAuthorizationServerMetadata("https://mcp.example.com") + ).rejects.toThrow("HTTP 500"); + }); + + it("handles CORS errors with retry", async () => { + // First call fails with CORS + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry without headers succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://auth.example.com" + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should have headers + expect(calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version"); + + // Second call should not have headers (CORS retry) + expect(calls[1][1]?.headers).toBeUndefined(); + }); + + it("supports custom fetch function", async () => { + const customFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://auth.example.com", + { fetchFn: customFetch } + ); + + expect(metadata).toEqual(validOAuthMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("supports custom protocol version", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + "https://auth.example.com", + { protocolVersion: "2025-01-01" } + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + const [, options] = calls[0]; + expect(options.headers).toEqual({ + "MCP-Protocol-Version": "2025-01-01" + }); + }); + }); + describe("startAuthorization", () => { const validMetadata = { issuer: "https://auth.example.com", @@ -909,7 +1127,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", - addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: OAuthMetadata) => { + addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata: AuthorizationServerMetadata) => { headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret)); params.set("example_url", typeof url === 'string' ? url : url.toString()); params.set("example_metadata", metadata.authorization_endpoint); @@ -1091,7 +1309,7 @@ describe("OAuth Authorization", () => { metadata: validMetadata, clientInformation: validClientInfo, refreshToken: "refresh123", - addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata) => { + addClientAuthentication: (headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata) => { headers.set("Authorization", "Basic " + btoa(validClientInfo.client_id + ":" + validClientInfo.client_secret)); params.set("example_url", typeof url === 'string' ? url : url.toString()); params.set("example_metadata", metadata?.authorization_endpoint ?? '?'); @@ -1919,17 +2137,17 @@ describe("OAuth Authorization", () => { // Verify the correct URLs were fetched const calls = mockFetch.mock.calls; - + // First call should be to PRM expect(calls[0][0].toString()).toBe("https://my.resource.com/.well-known/oauth-protected-resource/path/name"); - + // Second call should be to AS metadata with the path from authorization server expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth"); }); it("supports overriding the fetch function used for requests", async () => { const customFetch = jest.fn(); - + // Mock PRM discovery customFetch.mockResolvedValueOnce({ ok: true, @@ -1939,7 +2157,7 @@ describe("OAuth Authorization", () => { authorization_servers: ["https://auth.example.com"], }), }); - + // Mock AS metadata discovery customFetch.mockResolvedValueOnce({ ok: true, @@ -1956,7 +2174,7 @@ describe("OAuth Authorization", () => { const mockProvider: OAuthClientProvider = { get redirectUrl() { return "http://localhost:3000/callback"; }, - get clientMetadata() { + get clientMetadata() { return { client_name: "Test Client", redirect_uris: ["http://localhost:3000/callback"], @@ -1981,10 +2199,10 @@ describe("OAuth Authorization", () => { expect(result).toBe("REDIRECT"); expect(customFetch).toHaveBeenCalledTimes(2); expect(mockFetch).not.toHaveBeenCalled(); - + // Verify custom fetch was called for PRM discovery expect(customFetch.mock.calls[0][0].toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource"); - + // Verify custom fetch was called for AS metadata discovery expect(customFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 278bc1b4c..56826045a 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -7,7 +7,9 @@ import { OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata, - OAuthErrorResponseSchema + OAuthErrorResponseSchema, + AuthorizationServerMetadata, + OpenIdProviderDiscoveryMetadataSchema } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; @@ -108,7 +110,7 @@ export interface OAuthClientProvider { * @param url - The token endpoint URL being called * @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods */ - addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: OAuthMetadata): void | Promise; + addClientAuthentication?(headers: Headers, params: URLSearchParams, url: string | URL, metadata?: AuthorizationServerMetadata): void | Promise; /** * If defined, overrides the selection and validation of the @@ -319,7 +321,7 @@ async function authInternal( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl = serverUrl; + let authorizationServerUrl: string | URL | undefined; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { @@ -329,11 +331,19 @@ async function authInternal( // Ignore errors and fall back to /.well-known/oauth-authorization-server } + /** + * If we don't get a valid authorization server metadata from protected resource metadata, + * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server. + */ + if (!authorizationServerUrl) { + authorizationServerUrl = serverUrl; + } + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverOAuthMetadata(serverUrl, { - authorizationServerUrl - }, fetchFn); + const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { + fetchFn, + }); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -524,15 +534,21 @@ async function fetchWithCorsRetry( } /** - * Constructs the well-known path for OAuth metadata discovery + * Constructs the well-known path for auth-related metadata discovery */ -function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string { - let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`; +function buildWellKnownPath( + wellKnownPrefix: 'oauth-authorization-server' | 'oauth-protected-resource' | 'openid-configuration', + pathname: string = '', + options: { prependPathname?: boolean } = {} +): string { + // Strip trailing slash from pathname to avoid double slashes if (pathname.endsWith('/')) { - // Strip trailing slash from pathname to avoid double slashes - wellKnownPath = wellKnownPath.slice(0, -1); + pathname = pathname.slice(0, -1); } - return wellKnownPath; + + return options.prependPathname + ? `${pathname}/.well-known/${wellKnownPrefix}` + : `/.well-known/${wellKnownPrefix}${pathname}`; } /** @@ -594,6 +610,8 @@ async function discoverMetadataWithFallback( * * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. + * + * @deprecated This function is deprecated in favor of `discoverAuthorizationServerMetadata`. */ export async function discoverOAuthMetadata( issuer: string | URL, @@ -615,7 +633,7 @@ export async function discoverOAuthMetadata( if (typeof authorizationServerUrl === 'string') { authorizationServerUrl = new URL(authorizationServerUrl); } - protocolVersion ??= LATEST_PROTOCOL_VERSION; + protocolVersion ??= LATEST_PROTOCOL_VERSION ; const response = await discoverMetadataWithFallback( authorizationServerUrl, @@ -640,6 +658,137 @@ export async function discoverOAuthMetadata( return OAuthMetadataSchema.parse(await response.json()); } + +/** + * Builds a list of discovery URLs to try for authorization server metadata. + * URLs are returned in priority order: + * 1. OAuth metadata at the given URL + * 2. OAuth metadata at root (if URL has path) + * 3. OIDC metadata endpoints + */ +export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: URL; type: 'oauth' | 'oidc' }[] { + const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; + const hasPath = url.pathname !== '/'; + const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = []; + + + if (!hasPath) { + // Root path: https://example.com/.well-known/oauth-authorization-server + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth' + }); + + // OIDC: https://example.com/.well-known/openid-configuration + urlsToTry.push({ + url: new URL(`/.well-known/openid-configuration`, url.origin), + type: 'oidc' + }); + + return urlsToTry; + } + + // Strip trailing slash from pathname to avoid double slashes + let pathname = url.pathname; + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + // 1. OAuth metadata at the given URL + // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 + urlsToTry.push({ + url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin), + type: 'oauth' + }); + + // Root path: https://example.com/.well-known/oauth-authorization-server + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth' + }); + + // 3. OIDC metadata endpoints + // RFC 8414 style: Insert /.well-known/openid-configuration before the path + urlsToTry.push({ + url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin), + type: 'oidc' + }); + // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path + urlsToTry.push({ + url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), + type: 'oidc' + }); + + return urlsToTry; +} + +/** + * Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata + * and OpenID Connect Discovery 1.0 specifications. + * + * This function implements a fallback strategy for authorization server discovery: + * 1. Attempts RFC 8414 OAuth metadata discovery first + * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery + * + * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's + * protected resource metadata, or the MCP server's URL if the + * metadata was not found. + * @param options - Configuration options + * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch + * @param options.protocolVersion - MCP protocol version to use, defaults to LATEST_PROTOCOL_VERSION + * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + */ +export async function discoverAuthorizationServerMetadata( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion = LATEST_PROTOCOL_VERSION, + }: { + fetchFn?: FetchLike; + protocolVersion?: string; + } = {} +): Promise { + const headers = { 'MCP-Protocol-Version': protocolVersion }; + + // Get the list of URLs to try + const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); + + // Try each URL in order + for (const { url: endpointUrl, type } of urlsToTry) { + const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); + + if (!response) { + throw new Error(`CORS error trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`); + } + + if (!response.ok) { + // Continue looking for any 4xx response code. + if (response.status >= 400 && response.status < 500) { + continue; // Try next URL + } + throw new Error(`HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`); + } + + // Parse and validate based on type + if (type === 'oauth') { + return OAuthMetadataSchema.parse(await response.json()); + } else { + const metadata = OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + + // MCP spec requires OIDC providers to support S256 PKCE + if (!metadata.code_challenge_methods_supported?.includes('S256')) { + throw new Error( + `Incompatible OIDC provider at ${endpointUrl}: does not support S256 code challenge method required by MCP specification` + ); + } + + return metadata; + } + } + + return undefined; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ @@ -653,7 +802,7 @@ export async function startAuthorization( state, resource, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; @@ -746,7 +895,7 @@ export async function exchangeAuthorization( addClientAuthentication, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; @@ -832,7 +981,7 @@ export async function refreshAuthorization( addClientAuthentication, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; refreshToken: string; resource?: URL; @@ -903,7 +1052,7 @@ export async function registerClient( clientMetadata, fetchFn, }: { - metadata?: OAuthMetadata; + metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; fetchFn?: FetchLike; }, diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 24bfe094c..4fce9976f 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -352,6 +352,11 @@ describe("SSEClientTransport", () => { }); describe("auth handling", () => { + const authServerMetadataUrls = [ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + ]; + let mockAuthProvider: jest.Mocked; beforeEach(() => { @@ -608,7 +613,7 @@ describe("SSEClientTransport", () => { authServer.close(); authServer = createServer((req, res) => { - if (req.url === "/.well-known/oauth-authorization-server") { + if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } @@ -730,7 +735,7 @@ describe("SSEClientTransport", () => { authServer.close(); authServer = createServer((req, res) => { - if (req.url === "/.well-known/oauth-authorization-server") { + if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } @@ -875,7 +880,7 @@ describe("SSEClientTransport", () => { authServer.close(); authServer = createServer((req, res) => { - if (req.url === "/.well-known/oauth-authorization-server") { + if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 467680a56..47eba9ac5 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -56,6 +56,68 @@ export const OAuthMetadataSchema = z }) .passthrough(); +/** + * OpenID Connect Discovery 1.0 Provider Metadata + * see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ +export const OpenIdProviderMetadataSchema = z + .object({ + issuer: z.string(), + authorization_endpoint: z.string(), + token_endpoint: z.string(), + userinfo_endpoint: z.string().optional(), + jwks_uri: z.string(), + registration_endpoint: z.string().optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + response_modes_supported: z.array(z.string()).optional(), + grant_types_supported: z.array(z.string()).optional(), + acr_values_supported: z.array(z.string()).optional(), + subject_types_supported: z.array(z.string()), + id_token_signing_alg_values_supported: z.array(z.string()), + id_token_encryption_alg_values_supported: z.array(z.string()).optional(), + id_token_encryption_enc_values_supported: z.array(z.string()).optional(), + userinfo_signing_alg_values_supported: z.array(z.string()).optional(), + userinfo_encryption_alg_values_supported: z.array(z.string()).optional(), + userinfo_encryption_enc_values_supported: z.array(z.string()).optional(), + request_object_signing_alg_values_supported: z.array(z.string()).optional(), + request_object_encryption_alg_values_supported: z + .array(z.string()) + .optional(), + request_object_encryption_enc_values_supported: z + .array(z.string()) + .optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + display_values_supported: z.array(z.string()).optional(), + claim_types_supported: z.array(z.string()).optional(), + claims_supported: z.array(z.string()).optional(), + service_documentation: z.string().optional(), + claims_locales_supported: z.array(z.string()).optional(), + ui_locales_supported: z.array(z.string()).optional(), + claims_parameter_supported: z.boolean().optional(), + request_parameter_supported: z.boolean().optional(), + request_uri_parameter_supported: z.boolean().optional(), + require_request_uri_registration: z.boolean().optional(), + op_policy_uri: z.string().optional(), + op_tos_uri: z.string().optional(), + }) + .passthrough(); + +/** + * OpenID Connect Discovery metadata that may include OAuth 2.0 fields + * This schema represents the real-world scenario where OIDC providers + * return a mix of OpenID Connect and OAuth 2.0 metadata fields + */ +export const OpenIdProviderDiscoveryMetadataSchema = + OpenIdProviderMetadataSchema.merge( + OAuthMetadataSchema.pick({ + code_challenge_methods_supported: true, + }) + ); + /** * OAuth 2.1 token response */ @@ -133,8 +195,10 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); - export type OAuthMetadata = z.infer; +export type OpenIdProviderMetadata = z.infer; +export type OpenIdProviderDiscoveryMetadata = z.infer; + export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; export type OAuthClientMetadata = z.infer; @@ -143,3 +207,6 @@ export type OAuthClientInformationFull = z.infer; export type OAuthTokenRevocationRequest = z.infer; export type OAuthProtectedResourceMetadata = z.infer; + +// Unified type for authorization server metadata +export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; From 1fc452ea0ef355429af06654cc3ea36a903935b8 Mon Sep 17 00:00:00 2001 From: Theresa <63280168+sd0ric4@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:50:48 +0800 Subject: [PATCH 179/208] fix: remove extraneous code block in README.md (#791) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 4684c67c7..f1839845c 100644 --- a/README.md +++ b/README.md @@ -571,7 +571,6 @@ 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. Read the following section for examples. -> ``` #### CORS Configuration for Browser-Based Clients From b8ec6653eb1e0a177106debc1349ae1d986c1494 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:03:40 +0100 Subject: [PATCH 180/208] Bump form-data from 4.0.2 to 4.0.4 in the npm_and_yarn group across 1 directory (#798) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 254a8e71d..db470d181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3673,15 +3673,16 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { From 222db4a9c1ab4f023dc1dd1687212bddd522f48e Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 24 Jul 2025 18:15:42 +0100 Subject: [PATCH 181/208] Bump version 1.17.0 (#810) --- 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 db470d181..303dfbfd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.16.0", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.16.0", + "version": "1.17.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 1bd2cea91..c861b5358 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.16.0", + "version": "1.17.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From ee16173865f0a2cf084ac75929f9bbbce3148389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Tue, 29 Jul 2025 15:06:27 +0200 Subject: [PATCH 182/208] (fix): Update fallbackRequestHandler type to match _requestHandlers leaves type (#784) --- src/shared/protocol.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 6142140dd..b19a6c5ca 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -217,7 +217,10 @@ export abstract class Protocol< /** * A handler to invoke for any request types that do not have their own handler installed. */ - fallbackRequestHandler?: (request: Request) => Promise; + fallbackRequestHandler?: ( + request: JSONRPCRequest, + extra: RequestHandlerExtra + ) => Promise; /** * A handler to invoke for any notification types that do not have their own handler installed. From 31acdcbb189056ec83d14a4f7a37ae2b1c67680e Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Tue, 29 Jul 2025 21:07:01 +0800 Subject: [PATCH 183/208] fix: prevent responses being sent to wrong client when multiple transports connect (#820) --- .../protocol-transport-handling.test.ts | 189 ++++++++++++++++++ src/shared/protocol.ts | 11 +- 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 src/shared/protocol-transport-handling.test.ts diff --git a/src/shared/protocol-transport-handling.test.ts b/src/shared/protocol-transport-handling.test.ts new file mode 100644 index 000000000..3baa9b638 --- /dev/null +++ b/src/shared/protocol-transport-handling.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test, beforeEach } from "@jest/globals"; +import { Protocol } from "./protocol.js"; +import { Transport } from "./transport.js"; +import { Request, Notification, Result, JSONRPCMessage } from "../types.js"; +import { z } from "zod"; + +// Mock Transport class +class MockTransport implements Transport { + id: string; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + sentMessages: JSONRPCMessage[] = []; + + constructor(id: string) { + this.id = id; + } + + async start(): Promise {} + + async close(): Promise { + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + this.sentMessages.push(message); + } +} + +describe("Protocol transport handling bug", () => { + let protocol: Protocol; + let transportA: MockTransport; + let transportB: MockTransport; + + beforeEach(() => { + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + })(); + + transportA = new MockTransport("A"); + transportB = new MockTransport("B"); + }); + + test("should send response to the correct transport when multiple clients are connected", async () => { + // Set up a request handler that simulates processing time + let resolveHandler: (value: Result) => void; + const handlerPromise = new Promise((resolve) => { + resolveHandler = resolve; + }); + + const TestRequestSchema = z.object({ + method: z.literal("test/method"), + params: z.object({ + from: z.string() + }).optional() + }); + + protocol.setRequestHandler( + TestRequestSchema, + async (request) => { + console.log(`Processing request from ${request.params?.from}`); + return handlerPromise; + } + ); + + // Client A connects and sends a request + await protocol.connect(transportA); + + const requestFromA = { + jsonrpc: "2.0" as const, + method: "test/method", + params: { from: "clientA" }, + id: 1 + }; + + // Simulate client A sending a request + transportA.onmessage?.(requestFromA); + + // While A's request is being processed, client B connects + // This overwrites the transport reference in the protocol + await protocol.connect(transportB); + + const requestFromB = { + jsonrpc: "2.0" as const, + method: "test/method", + params: { from: "clientB" }, + id: 2 + }; + + // Client B sends its own request + transportB.onmessage?.(requestFromB); + + // Now complete A's request + resolveHandler!({ data: "responseForA" } as Result); + + // Wait for async operations to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Check where the responses went + console.log("Transport A received:", transportA.sentMessages); + console.log("Transport B received:", transportB.sentMessages); + + // FIXED: Each transport now receives its own response + + // Transport A should receive response for request ID 1 + expect(transportA.sentMessages.length).toBe(1); + expect(transportA.sentMessages[0]).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { data: "responseForA" } + }); + + // Transport B should only receive its own response (when implemented) + expect(transportB.sentMessages.length).toBe(1); + expect(transportB.sentMessages[0]).toMatchObject({ + jsonrpc: "2.0", + id: 2, + result: { data: "responseForA" } // Same handler result in this test + }); + }); + + test("demonstrates the timing issue with multiple rapid connections", async () => { + const delays: number[] = []; + const results: { transport: string; response: JSONRPCMessage[] }[] = []; + + const DelayedRequestSchema = z.object({ + method: z.literal("test/delayed"), + params: z.object({ + delay: z.number(), + client: z.string() + }).optional() + }); + + // Set up handler with variable delay + protocol.setRequestHandler( + DelayedRequestSchema, + async (request, extra) => { + const delay = request.params?.delay || 0; + delays.push(delay); + + await new Promise(resolve => setTimeout(resolve, delay)); + + return { + processedBy: `handler-${extra.requestId}`, + delay: delay + } as Result; + } + ); + + // Rapid succession of connections and requests + await protocol.connect(transportA); + transportA.onmessage?.({ + jsonrpc: "2.0" as const, + method: "test/delayed", + params: { delay: 50, client: "A" }, + id: 1 + }); + + // Connect B while A is processing + setTimeout(async () => { + await protocol.connect(transportB); + transportB.onmessage?.({ + jsonrpc: "2.0" as const, + method: "test/delayed", + params: { delay: 10, client: "B" }, + id: 2 + }); + }, 10); + + // Wait for all processing + await new Promise(resolve => setTimeout(resolve, 100)); + + // Collect results + if (transportA.sentMessages.length > 0) { + results.push({ transport: "A", response: transportA.sentMessages }); + } + if (transportB.sentMessages.length > 0) { + results.push({ transport: "B", response: transportB.sentMessages }); + } + + console.log("Timing test results:", results); + + // FIXED: Each transport receives its own responses + expect(transportA.sentMessages.length).toBe(1); + expect(transportB.sentMessages.length).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index b19a6c5ca..7df190ba1 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -370,8 +370,11 @@ export abstract class Protocol< const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + // Capture the current transport at request time to ensure responses go to the correct client + const capturedTransport = this._transport; + if (handler === undefined) { - this._transport + capturedTransport ?.send({ jsonrpc: "2.0", id: request.id, @@ -393,7 +396,7 @@ export abstract class Protocol< const fullExtra: RequestHandlerExtra = { signal: abortController.signal, - sessionId: this._transport?.sessionId, + sessionId: capturedTransport?.sessionId, _meta: request.params?._meta, sendNotification: (notification) => @@ -414,7 +417,7 @@ export abstract class Protocol< return; } - return this._transport?.send({ + return capturedTransport?.send({ result, jsonrpc: "2.0", id: request.id, @@ -425,7 +428,7 @@ export abstract class Protocol< return; } - return this._transport?.send({ + return capturedTransport?.send({ jsonrpc: "2.0", id: request.id, error: { From 0551cc52b8920d7da46a4519b42f335a0a852b6c Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 31 Jul 2025 19:33:29 +0100 Subject: [PATCH 184/208] 1.17.1 (#831) --- 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 303dfbfd4..dd45ff05d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.0", + "version": "1.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.0", + "version": "1.17.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index c861b5358..7bbb0f173 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.0", + "version": "1.17.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From f657ead4722cfb419d857965a59bd52164c9c99a Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 4 Aug 2025 22:43:09 +0800 Subject: [PATCH 185/208] fix: retry next endpoint on CORS error during auth server discovery (#827) --- src/client/auth.test.ts | 12 ++++++++++++ src/client/auth.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index c3049124e..fb9b31006 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -899,6 +899,18 @@ describe("OAuth Authorization", () => { "MCP-Protocol-Version": "2025-01-01" }); }); + + it("returns undefined when all URLs fail with CORS errors", async () => { + // All fetch attempts fail with CORS errors (TypeError) + mockFetch.mockImplementation(() => Promise.reject(new TypeError("CORS error"))); + + const metadata = await discoverAuthorizationServerMetadata("https://auth.example.com/tenant1"); + + expect(metadata).toBeUndefined(); + + // Verify that all discovery URLs were attempted + expect(mockFetch).toHaveBeenCalledTimes(8); // 4 URLs × 2 attempts each (with and without headers) + }); }); describe("startAuthorization", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 56826045a..ab8aff0c7 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -758,7 +758,11 @@ export async function discoverAuthorizationServerMetadata( const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); if (!response) { - throw new Error(`CORS error trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`); + /** + * CORS error occurred - don't throw as the endpoint may not allow CORS, + * continue trying other possible endpoints + */ + continue; } if (!response.ok) { From a1608a6513d18eb965266286904760f830de96fe Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 7 Aug 2025 21:28:37 +0100 Subject: [PATCH 186/208] 1.17.2 (#855) --- 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 dd45ff05d..2fdf89b2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.1", + "version": "1.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.1", + "version": "1.17.2", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 7bbb0f173..2f5a030bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.1", + "version": "1.17.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4d0977bc9169965233120e823c8024e210132ad9 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:23:07 -0400 Subject: [PATCH 187/208] Fix quit command in Example client with OAuth (#864) --- src/examples/client/simpleOAuthClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 4531f4c2a..b7388384a 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -270,7 +270,9 @@ class InteractiveOAuthClient { } if (command === 'quit') { - break; + console.log('\n👋 Goodbye!'); + this.close(); + process.exit(0); } else if (command === 'list') { await this.listTools(); } else if (command.startsWith('call ')) { From 69d4eb1227eaf8b60c9ea563657c8f2031acbdbe Mon Sep 17 00:00:00 2001 From: Jatin Sandilya <7681067+jatinsandilya@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:21:16 +0530 Subject: [PATCH 188/208] fix: client import & server imports (#851) --- package.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f5a030bb..445134892 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,18 @@ "mcp" ], "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + }, + "./client": { + "import": "./dist/esm/client/index.js", + "require": "./dist/cjs/client/index.js" + }, + "./server": { + "import": "./dist/esm/server/index.js", + "require": "./dist/cjs/server/index.js" + }, "./*": { "import": "./dist/esm/*", "require": "./dist/cjs/*" @@ -88,4 +100,4 @@ "resolutions": { "strip-ansi": "6.0.1" } -} +} \ No newline at end of file From aad33a15a8cc37a0e54a4d3fe71550588ae2df80 Mon Sep 17 00:00:00 2001 From: Arjun Kumar <90433405+arjunkmrm@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:06:18 +0800 Subject: [PATCH 189/208] =?UTF-8?q?fix:=20pass=20fetchfn=20parameter=20to?= =?UTF-8?q?=20registerClient=20and=20refreshAuthorizatio=E2=80=A6=20(#850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index ab8aff0c7..8ac9ddd1e 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -359,6 +359,7 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + fetchFn, }); await provider.saveClientInformation(fullInformation); @@ -395,6 +396,7 @@ async function authInternal( refreshToken: tokens.refresh_token, resource, addClientAuthentication: provider.addClientAuthentication, + fetchFn, }); await provider.saveTokens(newTokens); From 65119eb7d879e1a429717e887b279749359d7ec2 Mon Sep 17 00:00:00 2001 From: bianbianzhu Date: Fri, 15 Aug 2025 00:10:27 +1000 Subject: [PATCH 190/208] docs: correct parameter schema key in Dynamic Servers documentation (#789) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1839845c..cee7eb855 100644 --- a/README.md +++ b/README.md @@ -898,7 +898,7 @@ const upgradeAuthTool = server.tool( // If we've just upgraded to 'write' permissions, we can still call 'upgradeAuth' // but can only upgrade to 'admin'. upgradeAuthTool.update({ - paramSchema: { permission: z.enum(["admin"]) }, // change validation rules + paramsSchema: { permission: z.enum(["admin"]) }, // change validation rules }) } else { // If we're now an admin, we no longer have anywhere to upgrade to, so fully remove that tool From 4a63974049e27efb3c99325b29454127eed33adf Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:55:36 +0100 Subject: [PATCH 191/208] version: patch to 0.17.3 (#878) --- 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 2fdf89b2e..1e0b12ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.2", + "version": "1.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.2", + "version": "1.17.3", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 445134892..697b051be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.2", + "version": "1.17.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From abb2f0aa14f94f61be2e1f72711aa35284068f5b Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro <64930576+m-paternostro@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:11:15 +0100 Subject: [PATCH 192/208] =?UTF-8?q?feature(middleware):=20Composable=20fet?= =?UTF-8?q?ch=20middleware=20for=20auth=20and=20cross=E2=80=91cutting=20co?= =?UTF-8?q?ncerns=20=20(#485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Soria Parra Co-authored-by: Claude --- src/client/middleware.test.ts | 1213 +++++++++++++++++++++++++++++++++ src/client/middleware.ts | 358 ++++++++++ 2 files changed, 1571 insertions(+) create mode 100644 src/client/middleware.test.ts create mode 100644 src/client/middleware.ts diff --git a/src/client/middleware.test.ts b/src/client/middleware.test.ts new file mode 100644 index 000000000..265aa70d6 --- /dev/null +++ b/src/client/middleware.test.ts @@ -0,0 +1,1213 @@ +import { + withOAuth, + withLogging, + applyMiddlewares, + createMiddleware, +} from "./middleware.js"; +import { OAuthClientProvider } from "./auth.js"; +import { FetchLike } from "../shared/transport.js"; + +jest.mock("../client/auth.js", () => { + const actual = jest.requireActual("../client/auth.js"); + return { + ...actual, + auth: jest.fn(), + extractResourceMetadataUrl: jest.fn(), + }; +}); + +import { auth, extractResourceMetadataUrl } from "./auth.js"; + +const mockAuth = auth as jest.MockedFunction; +const mockExtractResourceMetadataUrl = + extractResourceMetadataUrl as jest.MockedFunction< + typeof extractResourceMetadataUrl + >; + +describe("withOAuth", () => { + let mockProvider: jest.Mocked; + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + mockProvider = { + get redirectUrl() { + return "http://localhost/callback"; + }, + get clientMetadata() { + return { redirect_uris: ["http://localhost/callback"] }; + }, + tokens: jest.fn(), + saveTokens: jest.fn(), + clientInformation: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + invalidateCredentials: jest.fn(), + }; + + mockFetch = jest.fn(); + }); + + it("should add Authorization header when tokens are available (with explicit baseUrl)", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("success", { status: 200 })); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("should add Authorization header when tokens are available (without baseUrl)", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("success", { status: 200 })); + + // Test without baseUrl - should extract from request URL + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("should handle requests without tokens (without baseUrl)", async () => { + mockProvider.tokens.mockResolvedValue(undefined); + mockFetch.mockResolvedValue(new Response("success", { status: 200 })); + + // Test without baseUrl + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBeNull(); + }); + + it("should retry request after successful auth on 401 response (with explicit baseUrl)", async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: "old-token", + token_type: "Bearer", + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: "new-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const unauthorizedResponse = new Response("Unauthorized", { + status: 401, + headers: { "www-authenticate": 'Bearer realm="oauth"' }, + }); + const successResponse = new Response("success", { status: 200 }); + + mockFetch + .mockResolvedValueOnce(unauthorizedResponse) + .mockResolvedValueOnce(successResponse); + + const mockResourceUrl = new URL( + "https://oauth.example.com/.well-known/oauth-protected-resource", + ); + mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl); + mockAuth.mockResolvedValue("AUTHORIZED"); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + const result = await enhancedFetch("https://api.example.com/data"); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: "https://api.example.com", + resourceMetadataUrl: mockResourceUrl, + fetchFn: mockFetch, + }); + + // Verify the retry used the new token + const retryCallArgs = mockFetch.mock.calls[1]; + const retryHeaders = retryCallArgs[1]?.headers as Headers; + expect(retryHeaders.get("Authorization")).toBe("Bearer new-token"); + }); + + it("should retry request after successful auth on 401 response (without baseUrl)", async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: "old-token", + token_type: "Bearer", + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: "new-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const unauthorizedResponse = new Response("Unauthorized", { + status: 401, + headers: { "www-authenticate": 'Bearer realm="oauth"' }, + }); + const successResponse = new Response("success", { status: 200 }); + + mockFetch + .mockResolvedValueOnce(unauthorizedResponse) + .mockResolvedValueOnce(successResponse); + + const mockResourceUrl = new URL( + "https://oauth.example.com/.well-known/oauth-protected-resource", + ); + mockExtractResourceMetadataUrl.mockReturnValue(mockResourceUrl); + mockAuth.mockResolvedValue("AUTHORIZED"); + + // Test without baseUrl - should extract from request URL + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + const result = await enhancedFetch("https://api.example.com/data"); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: "https://api.example.com", // Should be extracted from request URL + resourceMetadataUrl: mockResourceUrl, + fetchFn: mockFetch, + }); + + // Verify the retry used the new token + const retryCallArgs = mockFetch.mock.calls[1]; + const retryHeaders = retryCallArgs[1]?.headers as Headers; + expect(retryHeaders.get("Authorization")).toBe("Bearer new-token"); + }); + + it("should throw UnauthorizedError when auth returns REDIRECT (without baseUrl)", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("Unauthorized", { status: 401 })); + mockExtractResourceMetadataUrl.mockReturnValue(undefined); + mockAuth.mockResolvedValue("REDIRECT"); + + // Test without baseUrl + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await expect(enhancedFetch("https://api.example.com/data")).rejects.toThrow( + "Authentication requires user authorization - redirect initiated", + ); + }); + + it("should throw UnauthorizedError when auth fails", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("Unauthorized", { status: 401 })); + mockExtractResourceMetadataUrl.mockReturnValue(undefined); + mockAuth.mockRejectedValue(new Error("Network error")); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + await expect(enhancedFetch("https://api.example.com/data")).rejects.toThrow( + "Failed to re-authenticate: Network error", + ); + }); + + it("should handle persistent 401 responses after auth", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + // Always return 401 + mockFetch.mockResolvedValue(new Response("Unauthorized", { status: 401 })); + mockExtractResourceMetadataUrl.mockReturnValue(undefined); + mockAuth.mockResolvedValue("AUTHORIZED"); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + await expect(enhancedFetch("https://api.example.com/data")).rejects.toThrow( + "Authentication failed for https://api.example.com/data", + ); + + // Should have made initial request + 1 retry after auth = 2 total + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledTimes(1); + }); + + it("should preserve original request method and body", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("success", { status: 200 })); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + const requestBody = JSON.stringify({ data: "test" }); + await enhancedFetch("https://api.example.com/data", { + method: "POST", + body: requestBody, + headers: { "Content-Type": "application/json" }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + method: "POST", + body: requestBody, + headers: expect.any(Headers), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("should handle non-401 errors normally", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const serverErrorResponse = new Response("Server Error", { status: 500 }); + mockFetch.mockResolvedValue(serverErrorResponse); + + const enhancedFetch = withOAuth( + mockProvider, + "https://api.example.com", + )(mockFetch); + + const result = await enhancedFetch("https://api.example.com/data"); + + expect(result).toBe(serverErrorResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it("should handle URL object as input (without baseUrl)", async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + expires_in: 3600, + }); + + mockFetch.mockResolvedValue(new Response("success", { status: 200 })); + + // Test URL object without baseUrl - should extract origin from URL object + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch(new URL("https://api.example.com/data")); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + }); + + it("should handle URL object in auth retry (without baseUrl)", async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: "old-token", + token_type: "Bearer", + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: "new-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const unauthorizedResponse = new Response("Unauthorized", { status: 401 }); + const successResponse = new Response("success", { status: 200 }); + + mockFetch + .mockResolvedValueOnce(unauthorizedResponse) + .mockResolvedValueOnce(successResponse); + + mockExtractResourceMetadataUrl.mockReturnValue(undefined); + mockAuth.mockResolvedValue("AUTHORIZED"); + + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + const result = await enhancedFetch(new URL("https://api.example.com/data")); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: "https://api.example.com", // Should extract origin from URL object + resourceMetadataUrl: undefined, + fetchFn: mockFetch, + }); + }); +}); + +describe("withLogging", () => { + let mockFetch: jest.MockedFunction; + let mockLogger: jest.MockedFunction< + (input: { + method: string; + url: string | URL; + status: number; + statusText: string; + duration: number; + requestHeaders?: Headers; + responseHeaders?: Headers; + error?: Error; + }) => void + >; + let consoleErrorSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + mockFetch = jest.fn(); + mockLogger = jest.fn(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it("should log successful requests with default logger", async () => { + const response = new Response("success", { status: 200, statusText: "OK" }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging()(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringMatching( + /HTTP GET https:\/\/api\.example\.com\/data 200 OK \(\d+\.\d+ms\)/, + ), + ); + }); + + it("should log error responses with default logger", async () => { + const response = new Response("Not Found", { + status: 404, + statusText: "Not Found", + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging()(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching( + /HTTP GET https:\/\/api\.example\.com\/data 404 Not Found \(\d+\.\d+ms\)/, + ), + ); + }); + + it("should log network errors with default logger", async () => { + const networkError = new Error("Network connection failed"); + mockFetch.mockRejectedValue(networkError); + + const enhancedFetch = withLogging()(mockFetch); + + await expect(enhancedFetch("https://api.example.com/data")).rejects.toThrow( + "Network connection failed", + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching( + /HTTP GET https:\/\/api\.example\.com\/data failed: Network connection failed \(\d+\.\d+ms\)/, + ), + ); + }); + + it("should use custom logger when provided", async () => { + const response = new Response("success", { status: 200, statusText: "OK" }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch); + + await enhancedFetch("https://api.example.com/data", { method: "POST" }); + + expect(mockLogger).toHaveBeenCalledWith({ + method: "POST", + url: "https://api.example.com/data", + status: 200, + statusText: "OK", + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined, + }); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it("should include request headers when configured", async () => { + const response = new Response("success", { status: 200, statusText: "OK" }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + logger: mockLogger, + includeRequestHeaders: true, + })(mockFetch); + + await enhancedFetch("https://api.example.com/data", { + headers: { + Authorization: "Bearer token", + "Content-Type": "application/json", + }, + }); + + expect(mockLogger).toHaveBeenCalledWith({ + method: "GET", + url: "https://api.example.com/data", + status: 200, + statusText: "OK", + duration: expect.any(Number), + requestHeaders: expect.any(Headers), + responseHeaders: undefined, + }); + + const logCall = mockLogger.mock.calls[0][0]; + expect(logCall.requestHeaders?.get("Authorization")).toBe("Bearer token"); + expect(logCall.requestHeaders?.get("Content-Type")).toBe( + "application/json", + ); + }); + + it("should include response headers when configured", async () => { + const response = new Response("success", { + status: 200, + statusText: "OK", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }, + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + logger: mockLogger, + includeResponseHeaders: true, + })(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + const logCall = mockLogger.mock.calls[0][0]; + expect(logCall.responseHeaders?.get("Content-Type")).toBe( + "application/json", + ); + expect(logCall.responseHeaders?.get("Cache-Control")).toBe("no-cache"); + }); + + it("should respect statusLevel option", async () => { + const successResponse = new Response("success", { + status: 200, + statusText: "OK", + }); + const errorResponse = new Response("Server Error", { + status: 500, + statusText: "Internal Server Error", + }); + + mockFetch + .mockResolvedValueOnce(successResponse) + .mockResolvedValueOnce(errorResponse); + + const enhancedFetch = withLogging({ + logger: mockLogger, + statusLevel: 400, + })(mockFetch); + + // 200 response should not be logged (below statusLevel 400) + await enhancedFetch("https://api.example.com/success"); + expect(mockLogger).not.toHaveBeenCalled(); + + // 500 response should be logged (above statusLevel 400) + await enhancedFetch("https://api.example.com/error"); + expect(mockLogger).toHaveBeenCalledWith({ + method: "GET", + url: "https://api.example.com/error", + status: 500, + statusText: "Internal Server Error", + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined, + }); + }); + + it("should always log network errors regardless of statusLevel", async () => { + const networkError = new Error("Connection timeout"); + mockFetch.mockRejectedValue(networkError); + + const enhancedFetch = withLogging({ + logger: mockLogger, + statusLevel: 500, // Very high log level + })(mockFetch); + + await expect(enhancedFetch("https://api.example.com/data")).rejects.toThrow( + "Connection timeout", + ); + + expect(mockLogger).toHaveBeenCalledWith({ + method: "GET", + url: "https://api.example.com/data", + status: 0, + statusText: "Network Error", + duration: expect.any(Number), + requestHeaders: undefined, + error: networkError, + }); + }); + + it("should include headers in default logger message when configured", async () => { + const response = new Response("success", { + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + includeRequestHeaders: true, + includeResponseHeaders: true, + })(mockFetch); + + await enhancedFetch("https://api.example.com/data", { + headers: { Authorization: "Bearer token" }, + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Request Headers: {authorization: Bearer token}"), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Response Headers: {content-type: application/json}", + ), + ); + }); + + it("should measure request duration accurately", async () => { + // Mock a slow response + const response = new Response("success", { status: 200 }); + mockFetch.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return response; + }); + + const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + const logCall = mockLogger.mock.calls[0][0]; + expect(logCall.duration).toBeGreaterThanOrEqual(90); // Allow some margin for timing + }); +}); + +describe("applyMiddleware", () => { + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetch = jest.fn(); + }); + + it("should compose no middleware correctly", () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + const composedFetch = applyMiddlewares()(mockFetch); + + expect(composedFetch).toBe(mockFetch); + }); + + it("should compose single middleware correctly", async () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create a middleware that adds a header + const middleware1 = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("X-Middleware-1", "applied"); + return next(input, { ...init, headers }); + }; + + const composedFetch = applyMiddlewares(middleware1)(mockFetch); + + await composedFetch("https://api.example.com/data"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("X-Middleware-1")).toBe("applied"); + }); + + it("should compose multiple middleware in order", async () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create middleware that add identifying headers + const middleware1 = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("X-Middleware-1", "applied"); + return next(input, { ...init, headers }); + }; + + const middleware2 = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("X-Middleware-2", "applied"); + return next(input, { ...init, headers }); + }; + + const middleware3 = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("X-Middleware-3", "applied"); + return next(input, { ...init, headers }); + }; + + const composedFetch = applyMiddlewares( + middleware1, + middleware2, + middleware3, + )(mockFetch); + + await composedFetch("https://api.example.com/data"); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("X-Middleware-1")).toBe("applied"); + expect(headers.get("X-Middleware-2")).toBe("applied"); + expect(headers.get("X-Middleware-3")).toBe("applied"); + }); + + it("should work with real fetch middleware functions", async () => { + const response = new Response("success", { status: 200, statusText: "OK" }); + mockFetch.mockResolvedValue(response); + + // Create middleware that add identifying headers + const oauthMiddleware = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", "Bearer test-token"); + return next(input, { ...init, headers }); + }; + + // Use custom logger to avoid console output + const mockLogger = jest.fn(); + const composedFetch = applyMiddlewares( + oauthMiddleware, + withLogging({ logger: mockLogger, statusLevel: 0 }), + )(mockFetch); + + await composedFetch("https://api.example.com/data"); + + // Should have both Authorization header and logging + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer test-token"); + expect(mockLogger).toHaveBeenCalledWith({ + method: "GET", + url: "https://api.example.com/data", + status: 200, + statusText: "OK", + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined, + }); + }); + + it("should preserve error propagation through middleware", async () => { + const errorMiddleware = + (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + try { + return await next(input, init); + } catch (error) { + // Add context to the error + throw new Error( + `Middleware error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + const originalError = new Error("Network failure"); + mockFetch.mockRejectedValue(originalError); + + const composedFetch = applyMiddlewares(errorMiddleware)(mockFetch); + + await expect(composedFetch("https://api.example.com/data")).rejects.toThrow( + "Middleware error: Network failure", + ); + }); +}); + +describe("Integration Tests", () => { + let mockProvider: jest.Mocked; + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + mockProvider = { + get redirectUrl() { + return "http://localhost/callback"; + }, + get clientMetadata() { + return { redirect_uris: ["http://localhost/callback"] }; + }, + tokens: jest.fn(), + saveTokens: jest.fn(), + clientInformation: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + invalidateCredentials: jest.fn(), + }; + + mockFetch = jest.fn(); + }); + + it("should work with SSE transport pattern", async () => { + // Simulate how SSE transport might use the middleware + mockProvider.tokens.mockResolvedValue({ + access_token: "sse-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const response = new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + mockFetch.mockResolvedValue(response); + + // Use custom logger to avoid console output + const mockLogger = jest.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth( + mockProvider as OAuthClientProvider, + "https://mcp-server.example.com", + ), + withLogging({ logger: mockLogger, statusLevel: 400 }), // Only log errors + )(mockFetch); + + // Simulate SSE POST request + await enhancedFetch("https://mcp-server.example.com/endpoint", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "tools/list", + id: 1, + }), + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://mcp-server.example.com/endpoint", + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + body: expect.any(String), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer sse-token"); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it("should work with StreamableHTTP transport pattern", async () => { + // Simulate how StreamableHTTP transport might use the middleware + mockProvider.tokens.mockResolvedValue({ + access_token: "streamable-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const response = new Response(null, { + status: 202, + headers: { "mcp-session-id": "session-123" }, + }); + mockFetch.mockResolvedValue(response); + + // Use custom logger to avoid console output + const mockLogger = jest.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth( + mockProvider as OAuthClientProvider, + "https://streamable-server.example.com", + ), + withLogging({ + logger: mockLogger, + includeResponseHeaders: true, + statusLevel: 0, + }), + )(mockFetch); + + // Simulate StreamableHTTP initialization request + await enhancedFetch("https://streamable-server.example.com/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { protocolVersion: "2025-03-26", clientInfo: { name: "test" } }, + id: 1, + }), + }); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Bearer streamable-token"); + expect(headers.get("Accept")).toBe("application/json, text/event-stream"); + }); + + it("should handle auth retry in transport-like scenario", async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: "expired-token", + token_type: "Bearer", + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: "fresh-token", + token_type: "Bearer", + expires_in: 3600, + }); + + const unauthorizedResponse = new Response('{"error":"invalid_token"}', { + status: 401, + headers: { "www-authenticate": 'Bearer realm="mcp"' }, + }); + const successResponse = new Response( + '{"jsonrpc":"2.0","id":1,"result":{}}', + { + status: 200, + }, + ); + + mockFetch + .mockResolvedValueOnce(unauthorizedResponse) + .mockResolvedValueOnce(successResponse); + + mockExtractResourceMetadataUrl.mockReturnValue( + new URL("https://auth.example.com/.well-known/oauth-protected-resource"), + ); + mockAuth.mockResolvedValue("AUTHORIZED"); + + // Use custom logger to avoid console output + const mockLogger = jest.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth( + mockProvider as OAuthClientProvider, + "https://mcp-server.example.com", + ), + withLogging({ logger: mockLogger, statusLevel: 0 }), + )(mockFetch); + + const result = await enhancedFetch( + "https://mcp-server.example.com/endpoint", + { + method: "POST", + body: JSON.stringify({ jsonrpc: "2.0", method: "test", id: 1 }), + }, + ); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: "https://mcp-server.example.com", + resourceMetadataUrl: new URL( + "https://auth.example.com/.well-known/oauth-protected-resource", + ), + fetchFn: mockFetch, + }); + }); +}); + +describe("createMiddleware", () => { + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetch = jest.fn(); + }); + + it("should create middleware with cleaner syntax", async () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + const customMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set("X-Custom-Header", "custom-value"); + return next(input, { ...init, headers }); + }); + + const enhancedFetch = customMiddleware(mockFetch); + await enhancedFetch("https://api.example.com/data"); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ + headers: expect.any(Headers), + }), + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("X-Custom-Header")).toBe("custom-value"); + }); + + it("should support conditional middleware logic", async () => { + const apiResponse = new Response("api response", { status: 200 }); + const publicResponse = new Response("public response", { status: 200 }); + mockFetch + .mockResolvedValueOnce(apiResponse) + .mockResolvedValueOnce(publicResponse); + + const conditionalMiddleware = createMiddleware( + async (next, input, init) => { + const url = typeof input === "string" ? input : input.toString(); + + if (url.includes("/api/")) { + const headers = new Headers(init?.headers); + headers.set("X-API-Version", "v2"); + return next(input, { ...init, headers }); + } + + return next(input, init); + }, + ); + + const enhancedFetch = conditionalMiddleware(mockFetch); + + // Test API route + await enhancedFetch("https://example.com/api/users"); + let callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("X-API-Version")).toBe("v2"); + + // Test non-API route + await enhancedFetch("https://example.com/public/page"); + callArgs = mockFetch.mock.calls[1]; + const maybeHeaders = callArgs[1]?.headers as Headers | undefined; + expect(maybeHeaders?.get("X-API-Version")).toBeUndefined(); + }); + + it("should support short-circuit responses", async () => { + const customMiddleware = createMiddleware(async (next, input, init) => { + const url = typeof input === "string" ? input : input.toString(); + + // Short-circuit for specific URL + if (url.includes("/cached")) { + return new Response("cached data", { status: 200 }); + } + + return next(input, init); + }); + + const enhancedFetch = customMiddleware(mockFetch); + + // Test cached route (should not call mockFetch) + const cachedResponse = await enhancedFetch( + "https://example.com/cached/data", + ); + expect(await cachedResponse.text()).toBe("cached data"); + expect(mockFetch).not.toHaveBeenCalled(); + + // Test normal route + mockFetch.mockResolvedValue(new Response("fresh data", { status: 200 })); + const normalResponse = await enhancedFetch("https://example.com/normal/data"); + expect(await normalResponse.text()).toBe("fresh data"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should handle response transformation", async () => { + const originalResponse = new Response('{"data": "original"}', { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + mockFetch.mockResolvedValue(originalResponse); + + const transformMiddleware = createMiddleware(async (next, input, init) => { + const response = await next(input, init); + + if (response.headers.get("content-type")?.includes("application/json")) { + const data = await response.json(); + const transformed = { ...data, timestamp: 123456789 }; + + return new Response(JSON.stringify(transformed), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + + return response; + }); + + const enhancedFetch = transformMiddleware(mockFetch); + const response = await enhancedFetch("https://api.example.com/data"); + const result = await response.json(); + + expect(result).toEqual({ + data: "original", + timestamp: 123456789, + }); + }); + + it("should support error handling and recovery", async () => { + let attemptCount = 0; + mockFetch.mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) { + throw new Error("Network error"); + } + return new Response("success", { status: 200 }); + }); + + const retryMiddleware = createMiddleware(async (next, input, init) => { + try { + return await next(input, init); + } catch (error) { + // Retry once on network error + console.log("Retrying request after error:", error); + return await next(input, init); + } + }); + + const enhancedFetch = retryMiddleware(mockFetch); + const response = await enhancedFetch("https://api.example.com/data"); + + expect(await response.text()).toBe("success"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("should compose well with other middleware", async () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create custom middleware using createMiddleware + const customAuth = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", "Custom token"); + return next(input, { ...init, headers }); + }); + + const customLogging = createMiddleware(async (next, input, init) => { + const url = typeof input === "string" ? input : input.toString(); + console.log(`Request to: ${url}`); + const response = await next(input, init); + console.log(`Response status: ${response.status}`); + return response; + }); + + // Compose with existing middleware + const enhancedFetch = applyMiddlewares( + customAuth, + customLogging, + withLogging({ statusLevel: 400 }), + )(mockFetch); + + await enhancedFetch("https://api.example.com/data"); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs[1]?.headers as Headers; + expect(headers.get("Authorization")).toBe("Custom token"); + }); + + it("should have access to both input types (string and URL)", async () => { + const response = new Response("success", { status: 200 }); + mockFetch.mockResolvedValue(response); + + let capturedInputType: string | undefined; + const inspectMiddleware = createMiddleware(async (next, input, init) => { + capturedInputType = typeof input === "string" ? "string" : "URL"; + return next(input, init); + }); + + const enhancedFetch = inspectMiddleware(mockFetch); + + // Test with string input + await enhancedFetch("https://api.example.com/data"); + expect(capturedInputType).toBe("string"); + + // Test with URL input + await enhancedFetch(new URL("https://api.example.com/data")); + expect(capturedInputType).toBe("URL"); + }); +}); diff --git a/src/client/middleware.ts b/src/client/middleware.ts new file mode 100644 index 000000000..3d0661584 --- /dev/null +++ b/src/client/middleware.ts @@ -0,0 +1,358 @@ +import { + auth, + extractResourceMetadataUrl, + OAuthClientProvider, + UnauthorizedError, +} from "./auth.js"; +import { FetchLike } from "../shared/transport.js"; + +/** + * Middleware function that wraps and enhances fetch functionality. + * Takes a fetch handler and returns an enhanced fetch handler. + */ +export type Middleware = (next: FetchLike) => FetchLike; + +/** + * Creates a fetch wrapper that handles OAuth authentication automatically. + * + * This wrapper will: + * - Add Authorization headers with access tokens + * - Handle 401 responses by attempting re-authentication + * - Retry the original request after successful auth + * - Handle OAuth errors appropriately (InvalidClientError, etc.) + * + * The baseUrl parameter is optional and defaults to using the domain from the request URL. + * However, you should explicitly provide baseUrl when: + * - Making requests to multiple subdomains (e.g., api.example.com, cdn.example.com) + * - Using API paths that differ from OAuth discovery paths (e.g., requesting /api/v1/data but OAuth is at /) + * - The OAuth server is on a different domain than your API requests + * - You want to ensure consistent OAuth behavior regardless of request URLs + * + * For MCP transports, set baseUrl to the same URL you pass to the transport constructor. + * + * Note: This wrapper is designed for general-purpose fetch operations. + * MCP transports (SSE and StreamableHTTP) already have built-in OAuth handling + * and should not need this wrapper. + * + * @param provider - OAuth client provider for authentication + * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @returns A fetch middleware function + */ +export const withOAuth = + (provider: OAuthClientProvider, baseUrl?: string | URL): Middleware => + (next) => { + return async (input, init) => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + + // Add authorization header if tokens are available + const tokens = await provider.tokens(); + if (tokens) { + headers.set("Authorization", `Bearer ${tokens.access_token}`); + } + + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + // Handle 401 responses by attempting re-authentication + if (response.status === 401) { + try { + const resourceMetadataUrl = extractResourceMetadataUrl(response); + + // Use provided baseUrl or extract from request URL + const serverUrl = + baseUrl || + (typeof input === "string" ? new URL(input).origin : input.origin); + + const result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + fetchFn: next, + }); + + if (result === "REDIRECT") { + throw new UnauthorizedError( + "Authentication requires user authorization - redirect initiated", + ); + } + + if (result !== "AUTHORIZED") { + throw new UnauthorizedError( + `Authentication failed with result: ${result}`, + ); + } + + // Retry the request with fresh tokens + response = await makeRequest(); + } catch (error) { + if (error instanceof UnauthorizedError) { + throw error; + } + throw new UnauthorizedError( + `Failed to re-authenticate: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // If we still have a 401 after re-auth attempt, throw an error + if (response.status === 401) { + const url = typeof input === "string" ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; + +/** + * Logger function type for HTTP requests + */ +export type RequestLogger = (input: { + method: string; + url: string | URL; + status: number; + statusText: string; + duration: number; + requestHeaders?: Headers; + responseHeaders?: Headers; + error?: Error; +}) => void; + +/** + * Configuration options for the logging middleware + */ +export type LoggingOptions = { + /** + * Custom logger function, defaults to console logging + */ + logger?: RequestLogger; + + /** + * Whether to include request headers in logs + * @default false + */ + includeRequestHeaders?: boolean; + + /** + * Whether to include response headers in logs + * @default false + */ + includeResponseHeaders?: boolean; + + /** + * Status level filter - only log requests with status >= this value + * Set to 0 to log all requests, 400 to log only errors + * @default 0 + */ + statusLevel?: number; +}; + +/** + * Creates a fetch middleware that logs HTTP requests and responses. + * + * When called without arguments `withLogging()`, it uses the default logger that: + * - Logs successful requests (2xx) to `console.log` + * - Logs error responses (4xx/5xx) and network errors to `console.error` + * - Logs all requests regardless of status (statusLevel: 0) + * - Does not include request or response headers in logs + * - Measures and displays request duration in milliseconds + * + * Important: the default logger uses both `console.log` and `console.error` so it should not be used with + * `stdio` transports and applications. + * + * @param options - Logging configuration options + * @returns A fetch middleware function + */ +export const withLogging = (options: LoggingOptions = {}): Middleware => { + const { + logger, + includeRequestHeaders = false, + includeResponseHeaders = false, + statusLevel = 0, + } = options; + + const defaultLogger: RequestLogger = (input) => { + const { + method, + url, + status, + statusText, + duration, + requestHeaders, + responseHeaders, + error, + } = input; + + let message = error + ? `HTTP ${method} ${url} failed: ${error.message} (${duration}ms)` + : `HTTP ${method} ${url} ${status} ${statusText} (${duration}ms)`; + + // Add headers to message if requested + if (includeRequestHeaders && requestHeaders) { + const reqHeaders = Array.from(requestHeaders.entries()) + .map(([key, value]) => `${key}: ${value}`) + .join(", "); + message += `\n Request Headers: {${reqHeaders}}`; + } + + if (includeResponseHeaders && responseHeaders) { + const resHeaders = Array.from(responseHeaders.entries()) + .map(([key, value]) => `${key}: ${value}`) + .join(", "); + message += `\n Response Headers: {${resHeaders}}`; + } + + if (error || status >= 400) { + // eslint-disable-next-line no-console + console.error(message); + } else { + // eslint-disable-next-line no-console + console.log(message); + } + }; + + const logFn = logger || defaultLogger; + + return (next) => async (input, init) => { + const startTime = performance.now(); + const method = init?.method || "GET"; + const url = typeof input === "string" ? input : input.toString(); + const requestHeaders = includeRequestHeaders + ? new Headers(init?.headers) + : undefined; + + try { + const response = await next(input, init); + const duration = performance.now() - startTime; + + // Only log if status meets the log level threshold + if (response.status >= statusLevel) { + logFn({ + method, + url, + status: response.status, + statusText: response.statusText, + duration, + requestHeaders, + responseHeaders: includeResponseHeaders + ? response.headers + : undefined, + }); + } + + return response; + } catch (error) { + const duration = performance.now() - startTime; + + // Always log errors regardless of log level + logFn({ + method, + url, + status: 0, + statusText: "Network Error", + duration, + requestHeaders, + error: error as Error, + }); + + throw error; + } + }; +}; + +/** + * Composes multiple fetch middleware functions into a single middleware pipeline. + * Middleware are applied in the order they appear, creating a chain of handlers. + * + * @example + * ```typescript + * // Create a middleware pipeline that handles both OAuth and logging + * const enhancedFetch = applyMiddlewares( + * withOAuth(oauthProvider, 'https://api.example.com'), + * withLogging({ statusLevel: 400 }) + * )(fetch); + * + * // Use the enhanced fetch - it will handle auth and log errors + * const response = await enhancedFetch('https://api.example.com/data'); + * ``` + * + * @param middleware - Array of fetch middleware to compose into a pipeline + * @returns A single composed middleware function + */ +export const applyMiddlewares = ( + ...middleware: Middleware[] +): Middleware => { + return (next) => { + return middleware.reduce((handler, mw) => mw(handler), next); + }; +}; + +/** + * Helper function to create custom fetch middleware with cleaner syntax. + * Provides the next handler and request details as separate parameters for easier access. + * + * @example + * ```typescript + * // Create custom authentication middleware + * const customAuthMiddleware = createMiddleware(async (next, input, init) => { + * const headers = new Headers(init?.headers); + * headers.set('X-Custom-Auth', 'my-token'); + * + * const response = await next(input, { ...init, headers }); + * + * if (response.status === 401) { + * console.log('Authentication failed'); + * } + * + * return response; + * }); + * + * // Create conditional middleware + * const conditionalMiddleware = createMiddleware(async (next, input, init) => { + * const url = typeof input === 'string' ? input : input.toString(); + * + * // Only add headers for API routes + * if (url.includes('/api/')) { + * const headers = new Headers(init?.headers); + * headers.set('X-API-Version', 'v2'); + * return next(input, { ...init, headers }); + * } + * + * // Pass through for non-API routes + * return next(input, init); + * }); + * + * // Create caching middleware + * const cacheMiddleware = createMiddleware(async (next, input, init) => { + * const cacheKey = typeof input === 'string' ? input : input.toString(); + * + * // Check cache first + * const cached = await getFromCache(cacheKey); + * if (cached) { + * return new Response(cached, { status: 200 }); + * } + * + * // Make request and cache result + * const response = await next(input, init); + * if (response.ok) { + * await saveToCache(cacheKey, await response.clone().text()); + * } + * + * return response; + * }); + * ``` + * + * @param handler - Function that receives the next handler and request parameters + * @returns A fetch middleware function + */ +export const createMiddleware = ( + handler: ( + next: FetchLike, + input: string | URL, + init?: RequestInit, + ) => Promise, +): Middleware => { + return (next) => (input, init) => handler(next, input as string | URL, init); +}; From 64f7cdd09bf99031510b52a1e957e40febea997b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 Aug 2025 15:39:21 +0100 Subject: [PATCH 193/208] restrict url schemes allowed in oauth metadata (#877) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude --- src/shared/auth.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++ src/shared/auth.ts | 59 +++++++++++++------- 2 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/shared/auth.test.ts diff --git a/src/shared/auth.test.ts b/src/shared/auth.test.ts new file mode 100644 index 000000000..c1ed82ba2 --- /dev/null +++ b/src/shared/auth.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from '@jest/globals'; +import { + SafeUrlSchema, + OAuthMetadataSchema, + OpenIdProviderMetadataSchema, + OAuthClientMetadataSchema, +} from './auth.js'; + +describe('SafeUrlSchema', () => { + it('accepts valid HTTPS URLs', () => { + expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com'); + expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize'); + }); + + it('accepts valid HTTP URLs', () => { + expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('rejects javascript: scheme URLs', () => { + expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); + + it('rejects invalid URLs', () => { + expect(() => SafeUrlSchema.parse('not-a-url')).toThrow(); + expect(() => SafeUrlSchema.parse('')).toThrow(); + }); + + it('works with safeParse', () => { + expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow(); + }); +}); + +describe('OAuthMetadataSchema', () => { + it('validates complete OAuth metadata', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + response_types_supported: ['code'], + scopes_supported: ['read', 'write'], + }; + + expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects metadata with javascript: URLs', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'javascript:alert(1)', + token_endpoint: 'https://auth.example.com/oauth/token', + response_types_supported: ['code'], + }; + + expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); + + it('requires mandatory fields', () => { + const incompleteMetadata = { + issuer: 'https://auth.example.com', + }; + + expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow(); + }); +}); + +describe('OpenIdProviderMetadataSchema', () => { + it('validates complete OpenID Provider metadata', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + jwks_uri: 'https://auth.example.com/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }; + + expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects metadata with javascript: in jwks_uri', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + jwks_uri: 'javascript:alert(1)', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }; + + expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); +}); + +describe('OAuthClientMetadataSchema', () => { + it('validates client metadata with safe URLs', () => { + const metadata = { + redirect_uris: ['https://app.example.com/callback'], + client_name: 'Test App', + client_uri: 'https://app.example.com', + }; + + expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects client metadata with javascript: redirect URIs', () => { + const metadata = { + redirect_uris: ['javascript:alert(1)'], + client_name: 'Test App', + }; + + expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); +}); diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 47eba9ac5..886eb1084 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,12 +1,35 @@ import { z } from "zod"; +/** + * Reusable URL validation that disallows javascript: scheme + */ +export const SafeUrlSchema = z.string().url() + .superRefine((val, ctx) => { + if (!URL.canParse(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "URL must be parseable", + fatal: true, + }); + + return z.NEVER; + } + }).refine( + (url) => { + const u = new URL(url); + return u.protocol !== 'javascript:' && u.protocol !== 'data:' && u.protocol !== 'vbscript:'; + }, + { message: "URL cannot use javascript:, data:, or vbscript: scheme" } +); + + /** * RFC 9728 OAuth Protected Resource Metadata */ export const OAuthProtectedResourceMetadataSchema = z .object({ resource: z.string().url(), - authorization_servers: z.array(z.string().url()).optional(), + authorization_servers: z.array(SafeUrlSchema).optional(), jwks_uri: z.string().url().optional(), scopes_supported: z.array(z.string()).optional(), bearer_methods_supported: z.array(z.string()).optional(), @@ -28,9 +51,9 @@ export const OAuthProtectedResourceMetadataSchema = z export const OAuthMetadataSchema = z .object({ issuer: z.string(), - authorization_endpoint: z.string(), - token_endpoint: z.string(), - registration_endpoint: z.string().optional(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), scopes_supported: z.array(z.string()).optional(), response_types_supported: z.array(z.string()), response_modes_supported: z.array(z.string()).optional(), @@ -39,8 +62,8 @@ export const OAuthMetadataSchema = z token_endpoint_auth_signing_alg_values_supported: z .array(z.string()) .optional(), - service_documentation: z.string().optional(), - revocation_endpoint: z.string().optional(), + service_documentation: SafeUrlSchema.optional(), + revocation_endpoint: SafeUrlSchema.optional(), revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), revocation_endpoint_auth_signing_alg_values_supported: z .array(z.string()) @@ -63,11 +86,11 @@ export const OAuthMetadataSchema = z export const OpenIdProviderMetadataSchema = z .object({ issuer: z.string(), - authorization_endpoint: z.string(), - token_endpoint: z.string(), - userinfo_endpoint: z.string().optional(), - jwks_uri: z.string(), - registration_endpoint: z.string().optional(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + userinfo_endpoint: SafeUrlSchema.optional(), + jwks_uri: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), scopes_supported: z.array(z.string()).optional(), response_types_supported: z.array(z.string()), response_modes_supported: z.array(z.string()).optional(), @@ -101,8 +124,8 @@ export const OpenIdProviderMetadataSchema = z request_parameter_supported: z.boolean().optional(), request_uri_parameter_supported: z.boolean().optional(), require_request_uri_registration: z.boolean().optional(), - op_policy_uri: z.string().optional(), - op_tos_uri: z.string().optional(), + op_policy_uri: SafeUrlSchema.optional(), + op_tos_uri: SafeUrlSchema.optional(), }) .passthrough(); @@ -146,18 +169,18 @@ export const OAuthErrorResponseSchema = z * RFC 7591 OAuth 2.0 Dynamic Client Registration metadata */ export const OAuthClientMetadataSchema = z.object({ - redirect_uris: z.array(z.string()).refine((uris) => uris.every((uri) => URL.canParse(uri)), { message: "redirect_uris must contain valid URLs" }), + redirect_uris: z.array(SafeUrlSchema), token_endpoint_auth_method: z.string().optional(), grant_types: z.array(z.string()).optional(), response_types: z.array(z.string()).optional(), client_name: z.string().optional(), - client_uri: z.string().optional(), - logo_uri: z.string().optional(), + client_uri: SafeUrlSchema.optional(), + logo_uri: SafeUrlSchema.optional(), scope: z.string().optional(), contacts: z.array(z.string()).optional(), - tos_uri: z.string().optional(), + tos_uri: SafeUrlSchema.optional(), policy_uri: z.string().optional(), - jwks_uri: z.string().optional(), + jwks_uri: SafeUrlSchema.optional(), jwks: z.any().optional(), software_id: z.string().optional(), software_version: z.string().optional(), From 1f5950be01927cb8e4e555cb5584c44db1604f39 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 Aug 2025 15:43:09 +0100 Subject: [PATCH 194/208] [auth] OAuth protected-resource-metadata: fallback on 4xx not just 404 (#879) Co-authored-by: adam jones --- src/client/auth.test.ts | 22 ++++++++-- src/client/auth.ts | 2 +- src/client/streamableHttp.test.ts | 70 +++++++++++++++++-------------- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index fb9b31006..f28163d14 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -212,11 +212,11 @@ describe("OAuth Authorization", () => { expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value"); }); - it("falls back to root discovery when path-aware discovery returns 404", async () => { - // First call (path-aware) returns 404 + it.each([400, 401, 403, 404, 410, 422, 429])("falls back to root discovery when path-aware discovery returns %d", async (statusCode) => { + // First call (path-aware) returns 4xx mockFetch.mockResolvedValueOnce({ ok: false, - status: 404, + status: statusCode, }); // Second call (root fallback) succeeds @@ -267,6 +267,20 @@ describe("OAuth Authorization", () => { expect(calls.length).toBe(2); }); + it("throws error on 500 status and does not fallback", async () => { + // First call (path-aware) returns 500 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) + .rejects.toThrow(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + }); + it("does not fallback when the original URL is already at root path", async () => { // First call (path-aware for root) returns 404 mockFetch.mockResolvedValueOnce({ @@ -907,7 +921,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverAuthorizationServerMetadata("https://auth.example.com/tenant1"); expect(metadata).toBeUndefined(); - + // Verify that all discovery URLs were attempted expect(mockFetch).toHaveBeenCalledTimes(8); // 4 URLs × 2 attempts each (with and without headers) }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 8ac9ddd1e..fcc320f17 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -571,7 +571,7 @@ async function tryMetadataDiscovery( * Determines if fallback to root discovery should be attempted */ function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { - return !response || response.status === 404 && pathname !== '/'; + return !response || (response.status >= 400 && response.status < 500) && pathname !== '/'; } /** diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 88fd48017..fdd35ed3f 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -465,7 +465,7 @@ describe("StreamableHTTPClientTransport", () => { // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); - + // Global fetch should never have been called expect(global.fetch).not.toHaveBeenCalled(); }); @@ -589,32 +589,32 @@ describe("StreamableHTTPClientTransport", () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); - + describe('Reconnection Logic', () => { let transport: StreamableHTTPClientTransport; - + // Use fake timers to control setTimeout and make the test instant. beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); - + it('should reconnect a GET-initiated notification stream that fails', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { reconnectionOptions: { - initialReconnectionDelay: 10, - maxRetries: 1, + initialReconnectionDelay: 10, + maxRetries: 1, maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity } }); - + const errorSpy = jest.fn(); transport.onerror = errorSpy; - + const failingStream = new ReadableStream({ start(controller) { controller.error(new Error("Network failure")); } }); - + const fetchMock = global.fetch as jest.Mock; // Mock the initial GET request, which will fail. fetchMock.mockResolvedValueOnce({ @@ -628,13 +628,13 @@ describe("StreamableHTTPClientTransport", () => { headers: new Headers({ "content-type": "text/event-stream" }), body: new ReadableStream(), }); - + // ACT await transport.start(); // Trigger the GET stream directly using the internal method for a clean test. await transport["_startOrAuthSse"]({}); await jest.advanceTimersByTimeAsync(20); // Trigger reconnection timeout - + // ASSERT expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), @@ -644,25 +644,25 @@ describe("StreamableHTTPClientTransport", () => { expect(fetchMock.mock.calls[0][1]?.method).toBe('GET'); expect(fetchMock.mock.calls[1][1]?.method).toBe('GET'); }); - + it('should NOT reconnect a POST-initiated stream that fails', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { - reconnectionOptions: { - initialReconnectionDelay: 10, - maxRetries: 1, + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity } }); - + const errorSpy = jest.fn(); transport.onerror = errorSpy; - + const failingStream = new ReadableStream({ start(controller) { controller.error(new Error("Network failure")); } }); - + const fetchMock = global.fetch as jest.Mock; // Mock the POST request. It returns a streaming content-type but a failing body. fetchMock.mockResolvedValueOnce({ @@ -670,7 +670,7 @@ describe("StreamableHTTPClientTransport", () => { headers: new Headers({ "content-type": "text/event-stream" }), body: failingStream, }); - + // A dummy request message to trigger the `send` logic. const requestMessage: JSONRPCRequest = { jsonrpc: '2.0', @@ -678,13 +678,13 @@ describe("StreamableHTTPClientTransport", () => { id: 'request-1', params: {}, }; - + // ACT await transport.start(); // Use the public `send` method to initiate a POST that gets a stream response. await transport.send(requestMessage); await jest.advanceTimersByTimeAsync(20); // Advance time to check for reconnections - + // ASSERT expect(errorSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('SSE stream disconnected: Error: Network failure'), @@ -718,7 +718,9 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) - // Resource discovery + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root .mockResolvedValueOnce(unauthedResponse) // OAuth metadata discovery .mockResolvedValueOnce({ @@ -770,7 +772,9 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) - // Resource discovery + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root .mockResolvedValueOnce(unauthedResponse) // OAuth metadata discovery .mockResolvedValueOnce({ @@ -822,7 +826,9 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) - // Resource discovery + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root .mockResolvedValueOnce(unauthedResponse) // OAuth metadata discovery .mockResolvedValueOnce({ @@ -888,7 +894,7 @@ describe("StreamableHTTPClientTransport", () => { ok: false, status: 404 }); - + // Create transport instance transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider, @@ -901,14 +907,14 @@ describe("StreamableHTTPClientTransport", () => { // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); - + // Verify specific OAuth endpoints were called with custom fetch const customFetchCalls = customFetch.mock.calls; const callUrls = customFetchCalls.map(([url]) => url.toString()); - + // Should have called resource metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); - + // Should have called OAuth authorization server metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); @@ -966,19 +972,19 @@ describe("StreamableHTTPClientTransport", () => { // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); - + // Verify specific OAuth endpoints were called with custom fetch const customFetchCalls = customFetch.mock.calls; const callUrls = customFetchCalls.map(([url]) => url.toString()); - + // Should have called resource metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); - + // Should have called OAuth authorization server metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); // Should have called token endpoint for authorization code exchange - const tokenCalls = customFetchCalls.filter(([url, options]) => + const tokenCalls = customFetchCalls.filter(([url, options]) => url.toString().includes('/token') && options?.method === "POST" ); expect(tokenCalls.length).toBeGreaterThan(0); From 3bc2235d747c320dfa0b6227cc84414c6d0add89 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:08:09 +0100 Subject: [PATCH 195/208] chore: bump version to 1.17.4 (#894) --- 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 1e0b12ed7..8759a701e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.3", + "version": "1.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.3", + "version": "1.17.4", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 697b051be..8be8f1002 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.3", + "version": "1.17.4", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 79d11dc53fda1dffcbfdad101d9832227134bf7c Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Wed, 27 Aug 2025 15:09:07 -0400 Subject: [PATCH 196/208] Automatic handling of logging level (#882) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../server/jsonResponseStreamableHttp.ts | 28 +++++----- src/examples/server/simpleSseServer.ts | 28 ++++------ .../server/simpleStatelessStreamableHttp.ts | 15 +++--- src/examples/server/simpleStreamableHttp.ts | 39 +++++++------- .../sseAndStreamableHttpCompatibleServer.ts | 17 +++---- src/server/index.ts | 51 +++++++++++++++++-- src/server/mcp.ts | 17 +++++-- 7 files changed, 116 insertions(+), 79 deletions(-) diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index d6501d275..bc740c5fa 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -44,27 +44,27 @@ const getServer = () => { { name: z.string().describe('Name to greet'), }, - async ({ name }, { sendNotification }): Promise => { + async ({ name }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); + await server.sendLoggingMessage({ + level: "debug", + data: `Starting multi-greet for ${name}` + }, extra.sessionId); await sleep(1000); // Wait 1 second before first greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Sending first greeting to ${name}` + }, extra.sessionId); await sleep(1000); // Wait another second before second greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Sending second greeting to ${name}` + }, extra.sessionId); return { content: [ @@ -170,4 +170,4 @@ app.listen(PORT, (error) => { process.on('SIGINT', async () => { console.log('Shutting down server...'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index f8bdd4662..664b15008 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -5,13 +5,13 @@ import { z } from 'zod'; import { CallToolResult } from '../../types.js'; /** - * This example server demonstrates the deprecated HTTP+SSE transport + * This example server demonstrates the deprecated HTTP+SSE transport * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. - * + * * The server exposes two endpoints: * - /mcp: For establishing the SSE stream (GET) * - /messages: For receiving client messages (POST) - * + * */ // Create an MCP server instance @@ -28,18 +28,15 @@ const getServer = () => { interval: z.number().describe('Interval in milliseconds between notifications').default(1000), count: z.number().describe('Number of notifications to send').default(10), }, - async ({ interval, count }, { sendNotification }): Promise => { + async ({ interval, count }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let counter = 0; // Send the initial notification - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Starting notification stream with ${count} messages every ${interval}ms` - } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Starting notification stream with ${count} messages every ${interval}ms` + }, extra.sessionId); // Send periodic notifications while (counter < count) { @@ -47,13 +44,10 @@ const getServer = () => { await sleep(interval); try { - await sendNotification({ - method: "notifications/message", - params: { + await server.sendLoggingMessage({ level: "info", data: `Notification #${counter} at ${new Date().toISOString()}` - } - }); + }, extra.sessionId); } catch (error) { console.error("Error sending notification:", error); @@ -169,4 +163,4 @@ process.on('SIGINT', async () => { } console.log('Server shutdown complete'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index b5a1e291e..d91f3a7b5 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -42,20 +42,17 @@ const getServer = () => { interval: z.number().describe('Interval in milliseconds between notifications').default(100), count: z.number().describe('Number of notifications to send (0 for 100)').default(10), }, - async ({ interval, count }, { sendNotification }): Promise => { + async ({ interval, count }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let counter = 0; while (count === 0 || counter < count) { counter++; try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, extra.sessionId); } catch (error) { console.error("Error sending notification:", error); @@ -170,4 +167,4 @@ app.listen(PORT, (error) => { process.on('SIGINT', async () => { console.log('Shutting down server...'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 98f9d351c..3271e6213 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -58,27 +58,27 @@ const getServer = () => { readOnlyHint: true, openWorldHint: false }, - async ({ name }, { sendNotification }): Promise => { + async ({ name }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); + await server.sendLoggingMessage({ + level: "debug", + data: `Starting multi-greet for ${name}` + }, extra.sessionId); await sleep(1000); // Wait 1 second before first greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Sending first greeting to ${name}` + }, extra.sessionId); await sleep(1000); // Wait another second before second greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Sending second greeting to ${name}` + }, extra.sessionId); return { content: [ @@ -273,20 +273,17 @@ const getServer = () => { interval: z.number().describe('Interval in milliseconds between notifications').default(100), count: z.number().describe('Number of notifications to send (0 for 100)').default(50), }, - async ({ interval, count }, { sendNotification }): Promise => { + async ({ interval, count }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let counter = 0; while (count === 0 || counter < count) { counter++; try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); + await server.sendLoggingMessage( { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, extra.sessionId); } catch (error) { console.error("Error sending notification:", error); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index e097ca70e..a9d9b63d7 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -12,7 +12,7 @@ import cors from 'cors'; * This example server demonstrates backwards compatibility with both: * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) * 2. The Streamable HTTP transport (protocol version 2025-03-26) - * + * * It maintains a single MCP server instance but exposes two transport options: * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) @@ -33,20 +33,17 @@ const getServer = () => { interval: z.number().describe('Interval in milliseconds between notifications').default(100), count: z.number().describe('Number of notifications to send (0 for 100)').default(50), }, - async ({ interval, count }, { sendNotification }): Promise => { + async ({ interval, count }, extra): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); let counter = 0; while (count === 0 || counter < count) { counter++; try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); + await server.sendLoggingMessage({ + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, extra.sessionId); } catch (error) { console.error("Error sending notification:", error); @@ -254,4 +251,4 @@ process.on('SIGINT', async () => { } console.log('Server shutdown complete'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/server/index.ts b/src/server/index.ts index 10ae2fadc..b1f71ea28 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -32,6 +32,9 @@ import { ServerRequest, ServerResult, SUPPORTED_PROTOCOL_VERSIONS, + LoggingLevel, + SetLevelRequestSchema, + LoggingLevelSchema } from "../types.js"; import Ajv from "ajv"; @@ -108,8 +111,36 @@ export class Server< this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.(), ); + + if (this._capabilities.logging) { + this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { + const transportSessionId: string | undefined = extra.sessionId || extra.requestInfo?.headers['mcp-session-id'] as string || undefined; + const { level } = request.params; + const parseResult = LoggingLevelSchema.safeParse(level); + if (transportSessionId && parseResult.success) { + this._loggingLevels.set(transportSessionId, parseResult.data); + } + return {}; + }) + } } + // Map log levels by session id + private _loggingLevels = new Map(); + + // Map LogLevelSchema to severity index + private readonly LOG_LEVEL_SEVERITY = new Map( + LoggingLevelSchema.options.map((level, index) => [level, index]) + ); + + // Is a message with the given level ignored in the log level set for the given session id? + private isMessageIgnored = (level: LoggingLevel, sessionId: string): boolean => { + const currentLevel = this._loggingLevels.get(sessionId); + return (currentLevel) + ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! + : false; + }; + /** * Registers new capabilities. This can only be called before connecting to a transport. * @@ -121,7 +152,6 @@ export class Server< "Cannot register capabilities after connecting to transport", ); } - this._capabilities = mergeCapabilities(this._capabilities, capabilities); } @@ -324,10 +354,10 @@ export class Server< if (result.action === "accept" && result.content) { try { const ajv = new Ajv(); - + const validate = ajv.compile(params.requestedSchema); const isValid = validate(result.content); - + if (!isValid) { throw new McpError( ErrorCode.InvalidParams, @@ -359,8 +389,19 @@ export class Server< ); } - async sendLoggingMessage(params: LoggingMessageNotification["params"]) { - return this.notification({ method: "notifications/message", params }); + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON RPC message + * @see LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + async sendLoggingMessage(params: LoggingMessageNotification["params"], sessionId?: string) { + if (this._capabilities.logging) { + if (!sessionId || !this.isMessageIgnored(params.level, sessionId)) { + return this.notification({method: "notifications/message", params}) + } + } } async sendResourceUpdated(params: ResourceUpdatedNotification["params"]) { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 791facef1..fb797a8b4 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -41,6 +41,7 @@ import { ServerRequest, ServerNotification, ToolAnnotations, + LoggingMessageNotification, } from "../types.js"; import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; @@ -822,7 +823,7 @@ export class McpServer { /** * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. - * + * * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ @@ -834,9 +835,9 @@ export class McpServer { /** * Registers a tool `name` (with a description) taking either parameter schema or annotations. - * This unified overload handles both `tool(name, description, paramsSchema, cb)` and + * This unified overload handles both `tool(name, description, paramsSchema, cb)` and * `tool(name, description, annotations, cb)` cases. - * + * * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ @@ -1047,6 +1048,16 @@ export class McpServer { return this.server.transport !== undefined } + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON RPC message + * @see LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + async sendLoggingMessage(params: LoggingMessageNotification["params"], sessionId?: string) { + return this.server.sendLoggingMessage(params, sessionId); + } /** * Sends a resource list changed event to the client, if connected. */ From 3dd074f8a25b92994e0e8cc69d3ffe9112a1f32b Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 29 Aug 2025 14:03:19 -0400 Subject: [PATCH 197/208] Fix the SDK vs Spec types test that is breaking CI (#908) --- src/spec.types.test.ts | 69 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 09cd6c2d0..09e411894 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -21,6 +21,12 @@ type RemovePassthrough = T extends object : {[K in keyof T as string extends K ? never : K]: RemovePassthrough} : T; +// Adds the `jsonrpc` property to a type, to match the on-wire format of notifications. +type WithJSONRPC = T & { jsonrpc: "2.0" }; + +// Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. +type WithJSONRPCRequest = T & { jsonrpc: "2.0"; id: SDKTypes.RequestId }; + type IsUnknown = [unknown] extends [T] ? [T] extends [unknown] ? true : false : false; // Turns {x?: unknown} into {x: unknown} but keeps {_meta?: unknown} unchanged (and leaves other optional properties unchanged, e.g. {x?: string}). @@ -48,7 +54,7 @@ type MakeUnknownsNotOptional = : T); function checkCancelledNotification( - sdk: SDKTypes.CancelledNotification, + sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification ) { sdk = spec; @@ -69,7 +75,7 @@ function checkImplementation( spec = sdk; } function checkProgressNotification( - sdk: SDKTypes.ProgressNotification, + sdk: WithJSONRPC, spec: SpecTypes.ProgressNotification ) { sdk = spec; @@ -77,21 +83,21 @@ function checkProgressNotification( } function checkSubscribeRequest( - sdk: SDKTypes.SubscribeRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.SubscribeRequest ) { sdk = spec; spec = sdk; } function checkUnsubscribeRequest( - sdk: SDKTypes.UnsubscribeRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.UnsubscribeRequest ) { sdk = spec; spec = sdk; } function checkPaginatedRequest( - sdk: SDKTypes.PaginatedRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.PaginatedRequest ) { sdk = spec; @@ -105,7 +111,7 @@ function checkPaginatedResult( spec = sdk; } function checkListRootsRequest( - sdk: SDKTypes.ListRootsRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ListRootsRequest ) { sdk = spec; @@ -126,7 +132,7 @@ function checkRoot( spec = sdk; } function checkElicitRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.ElicitRequest ) { sdk = spec; @@ -140,7 +146,7 @@ function checkElicitResult( spec = sdk; } function checkCompleteRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.CompleteRequest ) { sdk = spec; @@ -231,7 +237,7 @@ function checkClientResult( spec = sdk; } function checkClientNotification( - sdk: SDKTypes.ClientNotification, + sdk: WithJSONRPC, spec: SpecTypes.ClientNotification ) { sdk = spec; @@ -273,7 +279,7 @@ function checkTool( spec = sdk; } function checkListToolsRequest( - sdk: SDKTypes.ListToolsRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest ) { sdk = spec; @@ -294,42 +300,42 @@ function checkCallToolResult( spec = sdk; } function checkCallToolRequest( - sdk: SDKTypes.CallToolRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest ) { sdk = spec; spec = sdk; } function checkToolListChangedNotification( - sdk: SDKTypes.ToolListChangedNotification, + sdk: WithJSONRPC, spec: SpecTypes.ToolListChangedNotification ) { sdk = spec; spec = sdk; } function checkResourceListChangedNotification( - sdk: SDKTypes.ResourceListChangedNotification, + sdk: WithJSONRPC, spec: SpecTypes.ResourceListChangedNotification ) { sdk = spec; spec = sdk; } function checkPromptListChangedNotification( - sdk: SDKTypes.PromptListChangedNotification, + sdk: WithJSONRPC, spec: SpecTypes.PromptListChangedNotification ) { sdk = spec; spec = sdk; } function checkRootsListChangedNotification( - sdk: SDKTypes.RootsListChangedNotification, + sdk: WithJSONRPC, spec: SpecTypes.RootsListChangedNotification ) { sdk = spec; spec = sdk; } function checkResourceUpdatedNotification( - sdk: SDKTypes.ResourceUpdatedNotification, + sdk: WithJSONRPC, spec: SpecTypes.ResourceUpdatedNotification ) { sdk = spec; @@ -350,28 +356,28 @@ function checkCreateMessageResult( spec = sdk; } function checkSetLevelRequest( - sdk: SDKTypes.SetLevelRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.SetLevelRequest ) { sdk = spec; spec = sdk; } function checkPingRequest( - sdk: SDKTypes.PingRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.PingRequest ) { sdk = spec; spec = sdk; } function checkInitializedNotification( - sdk: SDKTypes.InitializedNotification, + sdk: WithJSONRPC, spec: SpecTypes.InitializedNotification ) { sdk = spec; spec = sdk; } function checkListResourcesRequest( - sdk: SDKTypes.ListResourcesRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourcesRequest ) { sdk = spec; @@ -385,7 +391,7 @@ function checkListResourcesResult( spec = sdk; } function checkListResourceTemplatesRequest( - sdk: SDKTypes.ListResourceTemplatesRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourceTemplatesRequest ) { sdk = spec; @@ -399,7 +405,7 @@ function checkListResourceTemplatesResult( spec = sdk; } function checkReadResourceRequest( - sdk: SDKTypes.ReadResourceRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest ) { sdk = spec; @@ -462,7 +468,7 @@ function checkPrompt( spec = sdk; } function checkListPromptsRequest( - sdk: SDKTypes.ListPromptsRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.ListPromptsRequest ) { sdk = spec; @@ -476,7 +482,7 @@ function checkListPromptsResult( spec = sdk; } function checkGetPromptRequest( - sdk: SDKTypes.GetPromptRequest, + sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest ) { sdk = spec; @@ -588,14 +594,14 @@ function checkJSONRPCMessage( spec = sdk; } function checkCreateMessageRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.CreateMessageRequest ) { sdk = spec; spec = sdk; } function checkInitializeRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.InitializeRequest ) { sdk = spec; @@ -623,28 +629,28 @@ function checkServerCapabilities( spec = sdk; } function checkClientRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.ClientRequest ) { sdk = spec; spec = sdk; } function checkServerRequest( - sdk: RemovePassthrough, + sdk: WithJSONRPCRequest>, spec: SpecTypes.ServerRequest ) { sdk = spec; spec = sdk; } function checkLoggingMessageNotification( - sdk: MakeUnknownsNotOptional, + sdk: MakeUnknownsNotOptional>, spec: SpecTypes.LoggingMessageNotification ) { sdk = spec; spec = sdk; } function checkServerNotification( - sdk: MakeUnknownsNotOptional, + sdk: MakeUnknownsNotOptional>, spec: SpecTypes.ServerNotification ) { sdk = spec; @@ -665,6 +671,7 @@ const SDK_TYPES_FILE = 'src/types.ts'; const MISSING_SDK_TYPES = [ // These are inlined in the SDK: 'Role', + 'Error', // The inner error object of a JSONRPCError // These aren't supported by the SDK yet: // TODO: Add definitions to the SDK @@ -685,7 +692,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(91); + expect(specTypes).toHaveLength(92); }); it('should have up to date list of missing sdk types', () => { From bf817939917277a4c59f2e19e7b44b8dd7ff140c Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Tue, 2 Sep 2025 16:46:39 +0100 Subject: [PATCH 198/208] bump version (#913) --- 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 8759a701e..c9380e731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.4", + "version": "1.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.4", + "version": "1.17.5", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 8be8f1002..b421b6eac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.4", + "version": "1.17.5", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 217756edf7b0f0c9dd796857e56b994ff4cb090d Mon Sep 17 00:00:00 2001 From: Jesse Lumarie Date: Tue, 9 Sep 2025 07:21:03 -0400 Subject: [PATCH 199/208] mcp: update SDK for SEP 973 + add to example server (#904) Co-authored-by: David Soria Parra <167242713+dsp-ant@users.noreply.github.com> --- src/examples/server/simpleStreamableHttp.ts | 4 +- src/types.ts | 48 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3271e6213..6f1e20080 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -21,7 +21,9 @@ const strictOAuth = process.argv.includes('--oauth-strict'); const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', - version: '1.0.0' + version: '1.0.0', + icons: [{src: './mcp.svg', sizes: '512x512', mimeType: 'image/svg+xml'}], + websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk', }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting diff --git a/src/types.ts b/src/types.ts index 323e37389..262e3b623 100644 --- a/src/types.ts +++ b/src/types.ts @@ -200,6 +200,26 @@ export const CancelledNotificationSchema = NotificationSchema.extend({ }); /* Base Metadata */ +/** + * Icon schema for use in tools, prompts, resources, and implementations. + */ +export const IconSchema = z + .object({ + /** + * URL or data URI for the icon. + */ + src: z.string(), + /** + * Optional MIME type for the icon. + */ + mimeType: z.optional(z.string()), + /** + * Optional string specifying icon dimensions (e.g., "48x48 96x96"). + */ + sizes: z.optional(z.string()), + }) + .passthrough(); + /** * Base metadata interface for common properties across resources, tools, prompts, and implementations. */ @@ -225,6 +245,19 @@ export const BaseMetadataSchema = z */ export const ImplementationSchema = BaseMetadataSchema.extend({ version: z.string(), + /** + * An optional URL of the website for this implementation. + */ + websiteUrl: z.optional(z.string()), + /** + * An optional list of icons for this implementation. + * This can be used by clients to display the implementation in a user interface. + * Each icon should have a `kind` property that specifies whether it is a data representation or a URL source, a `src` property that points to the icon file or data representation, and may also include a `mimeType` and `sizes` property. + * The `mimeType` property should be a valid MIME type for the icon file, such as "image/png" or "image/svg+xml". + * The `sizes` property should be a string that specifies one or more sizes at which the icon file can be used, such as "48x48" or "any" for scalable formats like SVG. + * The `sizes` property is optional, and if not provided, the client should assume that the icon can be used at any size. + */ + icons: z.optional(z.array(IconSchema)), }); /** @@ -506,6 +539,11 @@ export const ResourceSchema = BaseMetadataSchema.extend({ */ mimeType: z.optional(z.string()), + /** + * An optional list of icons for this resource. + */ + icons: z.optional(z.array(IconSchema)), + /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. @@ -672,6 +710,10 @@ export const PromptSchema = BaseMetadataSchema.extend({ * A list of arguments to use for templating the prompt. */ arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * An optional list of icons for this prompt. + */ + icons: z.optional(z.array(IconSchema)), /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. @@ -930,6 +972,11 @@ export const ToolSchema = BaseMetadataSchema.extend({ */ annotations: z.optional(ToolAnnotationsSchema), + /** + * An optional list of icons for this tool. + */ + icons: z.optional(z.array(IconSchema)), + /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. @@ -1535,6 +1582,7 @@ export type EmptyResult = Infer; export type CancelledNotification = Infer; /* Base Metadata */ +export type Icon = Infer; export type BaseMetadata = Infer; /* Initialization */ From 4de54fed0c6b85217da98e10dacf62696a4bbad4 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 9 Sep 2025 14:30:40 +0100 Subject: [PATCH 200/208] Add the checkIcon test, so that tests aren't failing anymore --- src/spec.types.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 09e411894..ae47d9de7 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -663,6 +663,13 @@ function checkLoggingLevel( sdk = spec; spec = sdk; } +function checkIcon( + sdk: RemovePassthrough, + spec: SpecTypes.Icon +) { + sdk = spec; + spec = sdk; +} // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) const SPEC_TYPES_FILE = 'spec.types.ts'; @@ -692,7 +699,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(92); + expect(specTypes).toHaveLength(93); }); it('should have up to date list of missing sdk types', () => { From 5dd7a2b700bb286e1d479befefff4635f02d5a6a Mon Sep 17 00:00:00 2001 From: Khanh Nguyen <163906601+knguyen-figma@users.noreply.github.com> Date: Tue, 9 Sep 2025 09:39:52 -0400 Subject: [PATCH 201/208] feat: add _meta field support to tool definitions (#922) Co-authored-by: David Soria Parra <167242713+dsp-ant@users.noreply.github.com> --- src/server/mcp.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 12 +++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 10e550df4..d9142702f 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1633,6 +1633,99 @@ describe("tool()", () => { ), ).rejects.toThrow(/Tool nonexistent-tool not found/); }); + + /*** + * Test: Tool Registration with _meta field + */ + test("should register tool with _meta field and include it in list response", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const metaData = { + author: "test-author", + version: "1.2.3", + category: "utility", + tags: ["test", "example"] + }; + + mcpServer.registerTool( + "test-with-meta", + { + description: "A tool with _meta field", + inputSchema: { name: z.string() }, + _meta: metaData, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test-with-meta"); + expect(result.tools[0].description).toBe("A tool with _meta field"); + expect(result.tools[0]._meta).toEqual(metaData); + }); + + /*** + * Test: Tool Registration without _meta field should have undefined _meta + */ + test("should register tool without _meta field and have undefined _meta in response", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerTool( + "test-without-meta", + { + description: "A tool without _meta field", + inputSchema: { name: z.string() }, + }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test-without-meta"); + expect(result.tools[0]._meta).toBeUndefined(); + }); }); describe("resource()", () => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index fb797a8b4..ac4880c99 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -123,6 +123,7 @@ export class McpServer { }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, + _meta: tool._meta, }; if (tool.outputSchema) { @@ -773,6 +774,7 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, + _meta: Record | undefined, callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { @@ -783,6 +785,7 @@ export class McpServer { outputSchema: outputSchema === undefined ? undefined : z.object(outputSchema), annotations, + _meta, callback, enabled: true, disable: () => registeredTool.update({ enabled: false }), @@ -798,6 +801,7 @@ export class McpServer { if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations + if (typeof updates._meta !== "undefined") registeredTool._meta = updates._meta if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled this.sendToolListChanged() }, @@ -915,7 +919,7 @@ export class McpServer { } const callback = rest[0] as ToolCallback; - return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback) + return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, undefined, callback) } /** @@ -929,6 +933,7 @@ export class McpServer { inputSchema?: InputArgs; outputSchema?: OutputArgs; annotations?: ToolAnnotations; + _meta?: Record; }, cb: ToolCallback ): RegisteredTool { @@ -936,7 +941,7 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema, outputSchema, annotations } = config; + const { title, description, inputSchema, outputSchema, annotations, _meta } = config; return this._createRegisteredTool( name, @@ -945,6 +950,7 @@ export class McpServer { inputSchema, outputSchema, annotations, + _meta, cb as ToolCallback ); } @@ -1173,6 +1179,7 @@ export type RegisteredTool = { inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; annotations?: ToolAnnotations; + _meta?: Record; callback: ToolCallback; enabled: boolean; enable(): void; @@ -1185,6 +1192,7 @@ export type RegisteredTool = { paramsSchema?: InputArgs, outputSchema?: OutputArgs, annotations?: ToolAnnotations, + _meta?: Record, callback?: ToolCallback, enabled?: boolean }): void From ebf39330f3a90fe9e74512788c11533a9be720e1 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 9 Sep 2025 16:35:08 -0400 Subject: [PATCH 202/208] Fix automatic log level handling for sessionless connections (#917) --- src/server/index.test.ts | 156 ++++++++++++++++++++++++++++++++++++++- src/server/index.ts | 8 +- 2 files changed, 158 insertions(+), 6 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 46205d726..664ed4520 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -15,7 +15,8 @@ import { ListResourcesRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, - ErrorCode + ErrorCode, + LoggingMessageNotification } from "../types.js"; import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; @@ -569,7 +570,7 @@ test("should allow elicitation reject and cancel without validation", async () = action: "decline", }); - // Test cancel - should not validate + // Test cancel - should not validate await expect( server.elicitInput({ message: "Please provide your name", @@ -861,3 +862,154 @@ test("should handle request timeout", async () => { code: ErrorCode.RequestTimeout, }); }); + +/* + Test automatic log level handling for transports with and without sessionId + */ +test("should respect log level for transport without sessionId", async () => { + + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(clientTransport.sessionId).toEqual(undefined); + + // Client sets logging level to warning + await client.setLoggingLevel("warning"); + + // This one will make it through + const warningParams: LoggingMessageNotification["params"] = { + level: "warning", + logger: "test server", + data: "Warning message", + }; + + // This one will not + const debugParams: LoggingMessageNotification["params"] = { + level: "debug", + logger: "test server", + data: "Debug message", + }; + + // Test the one that makes it through + clientTransport.onmessage = jest.fn().mockImplementation((message) => { + expect(message).toEqual({ + jsonrpc: "2.0", + method: "notifications/message", + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams); + expect(clientTransport.onmessage).toHaveBeenCalled(); + +}); + +test("should respect log level for transport with sessionId", async () => { + + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Add a session id to the transports + const SESSION_ID = "test-session-id"; + clientTransport.sessionId = SESSION_ID; + serverTransport.sessionId = SESSION_ID; + + expect(clientTransport.sessionId).toBeDefined(); + expect(serverTransport.sessionId).toBeDefined(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + + // Client sets logging level to warning + await client.setLoggingLevel("warning"); + + // This one will make it through + const warningParams: LoggingMessageNotification["params"] = { + level: "warning", + logger: "test server", + data: "Warning message", + }; + + // This one will not + const debugParams: LoggingMessageNotification["params"] = { + level: "debug", + logger: "test server", + data: "Debug message", + }; + + // Test the one that makes it through + clientTransport.onmessage = jest.fn().mockImplementation((message) => { + expect(message).toEqual({ + jsonrpc: "2.0", + method: "notifications/message", + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams, SESSION_ID); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams, SESSION_ID); + expect(clientTransport.onmessage).toHaveBeenCalled(); + +}); + diff --git a/src/server/index.ts b/src/server/index.ts index b1f71ea28..970657358 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -117,7 +117,7 @@ export class Server< const transportSessionId: string | undefined = extra.sessionId || extra.requestInfo?.headers['mcp-session-id'] as string || undefined; const { level } = request.params; const parseResult = LoggingLevelSchema.safeParse(level); - if (transportSessionId && parseResult.success) { + if (parseResult.success) { this._loggingLevels.set(transportSessionId, parseResult.data); } return {}; @@ -126,7 +126,7 @@ export class Server< } // Map log levels by session id - private _loggingLevels = new Map(); + private _loggingLevels = new Map(); // Map LogLevelSchema to severity index private readonly LOG_LEVEL_SEVERITY = new Map( @@ -134,7 +134,7 @@ export class Server< ); // Is a message with the given level ignored in the log level set for the given session id? - private isMessageIgnored = (level: LoggingLevel, sessionId: string): boolean => { + private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { const currentLevel = this._loggingLevels.get(sessionId); return (currentLevel) ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! @@ -398,7 +398,7 @@ export class Server< */ async sendLoggingMessage(params: LoggingMessageNotification["params"], sessionId?: string) { if (this._capabilities.logging) { - if (!sessionId || !this.isMessageIgnored(params.level, sessionId)) { + if (!this.isMessageIgnored(params.level, sessionId)) { return this.notification({method: "notifications/message", params}) } } From 68baf63583df8b7e2dab835a15c9cf81e285c174 Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 11 Sep 2025 18:38:05 +0100 Subject: [PATCH 203/208] 1.17.6 (#936) --- 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 c9380e731..b737f02ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.5", + "version": "1.17.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.5", + "version": "1.17.6", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index b421b6eac..3586f1f23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.5", + "version": "1.17.6", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 835286894c0cd80c5cd6580e8096a40ce2f28755 Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 11 Sep 2025 18:40:16 +0100 Subject: [PATCH 204/208] 1.18.0 (#937) --- 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 b737f02ea..14b3a9f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.6", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.17.6", + "version": "1.18.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 3586f1f23..fda002f99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.17.6", + "version": "1.18.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b28c297184cb0cb64611a3357d6438dd1b0824c6 Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Thu, 11 Sep 2025 20:34:09 +0100 Subject: [PATCH 205/208] ignore icons for now (#938) --- src/spec.types.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index ae47d9de7..5aa497f4a 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -685,6 +685,7 @@ const MISSING_SDK_TYPES = [ 'Annotations', 'ModelHint', 'ModelPreferences', + 'Icons', ] function extractExportedTypes(source: string): string[] { @@ -699,7 +700,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(93); + expect(specTypes).toHaveLength(94); }); it('should have up to date list of missing sdk types', () => { From 24b3972931395c70358146b00fec809625a550f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Wed, 17 Sep 2025 11:51:37 +0200 Subject: [PATCH 206/208] fix: prevent streamable http wite after end from crashing the node process (#933) --- src/server/streamableHttp.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 3bf84e430..c0da91704 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -303,6 +303,11 @@ export class StreamableHTTPServerTransport implements Transport { res.on("close", () => { this._streamMapping.delete(this._standaloneSseStreamId); }); + + // Add error handler for standalone SSE stream + res.on("error", (error) => { + this.onerror?.(error as Error); + }); } /** @@ -334,6 +339,11 @@ export class StreamableHTTPServerTransport implements Transport { } }); this._streamMapping.set(streamId, res); + + // Add error handler for replay stream + res.on("error", (error) => { + this.onerror?.(error as Error); + }); } catch (error) { this.onerror?.(error as Error); } @@ -520,6 +530,11 @@ export class StreamableHTTPServerTransport implements Transport { this._streamMapping.delete(streamId); }); + // Add error handler for stream write errors + res.on("error", (error) => { + this.onerror?.(error as Error); + }); + // handle each message for (const message of messages) { this.onmessage?.(message, { authInfo, requestInfo }); From c94ba4b43cd305e39d88985c73d6b9bc1153da84 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:27:58 +0100 Subject: [PATCH 207/208] chore: update version to 1.18.1 for weekly release (#950) --- 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 14b3a9f35..002fe2088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.18.0", + "version": "1.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.18.0", + "version": "1.18.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index fda002f99..bb36a6d98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.18.0", + "version": "1.18.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From c342dacb5ed55a0366fd2512bb642d1ceaa2ed87 Mon Sep 17 00:00:00 2001 From: Vinicius Costa Date: Mon, 22 Sep 2025 09:31:40 -0300 Subject: [PATCH 208/208] Updates the sampling code example in the README (#958) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cee7eb855..43b62ac60 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ mcpServer.registerTool( async function main() { const transport = new StdioServerTransport(); await mcpServer.connect(transport); - console.log("MCP server is running..."); + console.error("MCP server is running..."); } main().catch((error) => {