From 940054e71c190abec45768b06f23e61e0b79c831 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 11 Apr 2025 14:23:57 +0200 Subject: [PATCH 001/194] chore: fix typo Fixes small typo from https://github.com/modelcontextprotocol/typescript-sdk/pull/247. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fcdff17..3cb7ff07 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ putMessageTool.disable() const upgradeAuthTool = server.tool( "upgradeAuth", - { permission: z.enum(["write', vadmin"])}, + { permission: z.enum(["write', admin"])}, // Any mutations here will automatically emit `listChanged` notifications async ({ permission }) => { const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) From 52476aa29726cdf193b8d50b48e63feb5e1bff77 Mon Sep 17 00:00:00 2001 From: muzea Date: Wed, 23 Apr 2025 14:41:58 +0800 Subject: [PATCH 002/194] fix: add windows env PROGRAMFILES, avoid some exe can not be found --- src/client/stdio.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index b83bf27c..6572f0c0 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -56,6 +56,7 @@ export const DEFAULT_INHERITED_ENV_VARS = "TEMP", "USERNAME", "USERPROFILE", + "PROGRAMFILES", ] : /* list inspired by the default env inheritance of sudo */ ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; From 2deffb6c194154a2fb99dfcc3e53443debfda3a6 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 23 Apr 2025 22:10:12 -0700 Subject: [PATCH 003/194] test(server): add more tests forSSEServerTransport class --- package-lock.json | 4 +- src/server/sse.test.ts | 153 ++++++++++++++++++++++++++++++++++++++++- src/server/sse.ts | 2 +- 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1165b751..3c6e2d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "license": "MIT", "dependencies": { "content-type": "^1.0.5", diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..11705fe4 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -7,6 +7,7 @@ const createMockResponse = () => { writeHead: jest.fn(), write: jest.fn().mockReturnValue(true), on: jest.fn(), + end: jest.fn(), }; res.writeHead.mockReturnThis(); res.on.mockReturnThis(); @@ -14,6 +15,36 @@ const createMockResponse = () => { return res as unknown as http.ServerResponse; }; +const createMockRequest = ({ headers = {}, body }: { headers?: Record, body?: string } = {}) => { + const mockReq = { + headers, + body: body ? body : undefined, + auth: { + token: 'test-token', + }, + on: jest.fn().mockImplementation((event, listener) => { + const mockListener = listener as unknown as (...args: unknown[]) => void; + if (event === 'data') { + mockListener(Buffer.from(body || '') as unknown as Error); + } + if (event === 'error') { + mockListener(new Error('test')); + } + if (event === 'end') { + mockListener(); + } + if (event === 'close') { + setTimeout(listener, 100); + } + return mockReq; + }), + listeners: jest.fn(), + removeListener: jest.fn(), + } as unknown as http.IncomingMessage; + + return mockReq; +}; + describe('SSEServerTransport', () => { describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { @@ -106,4 +137,124 @@ describe('SSEServerTransport', () => { ); }); }); -}); + + describe('handlePostMessage method', () => { + it('should return 500 if server has not started', async () => { + const mockReq = createMockRequest(); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + + const error = 'SSE connection not established'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .rejects.toThrow(error); + expect(mockRes.writeHead).toHaveBeenCalledWith(500); + expect(mockRes.end).toHaveBeenCalledWith(error); + }); + + it('should return 400 if content-type is not application/json', async () => { + const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onerror = jest.fn(); + const error = 'Unsupported content-type: text/plain'; + await expect(transport.handlePostMessage(mockReq, mockRes)) + .resolves.toBe(undefined); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); + expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); + }); + + it('should return 400 if message has not a valid schema', async () => { + const invalidMessage = JSON.stringify({ + // missing jsonrpc field + method: 'call', + params: [1, 2, 3], + id: 1, + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: invalidMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(transport.onmessage).not.toHaveBeenCalled(); + expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); + }); + + it('should return 202 if message has a valid schema', async () => { + const validMessage = JSON.stringify({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }) + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: validMessage, + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = jest.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(202); + expect(mockRes.end).toHaveBeenCalledWith('Accepted'); + expect(transport.onmessage).toHaveBeenCalledWith({ + jsonrpc: "2.0", + method: 'call', + params: { + a: 1, + b: 2, + c: 3, + }, + id: 1 + }, { + authInfo: { + token: 'test-token', + } + }); + }); + }); + + describe('close method', () => { + it('should call onclose', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + transport.onclose = jest.fn(); + await transport.close(); + expect(transport.onclose).toHaveBeenCalled(); + }); + }); + + describe('send method', () => { + it('should call onsend', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining('event: endpoint')); + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); + }); + }); +}); \ No newline at end of file diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..164780ef 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -92,7 +92,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From 37ca02d36df56487430f13a6b4b6c0889647933b Mon Sep 17 00:00:00 2001 From: Nikhil Taneja <80670724+its-nikhil@users.noreply.github.com> Date: Thu, 24 Apr 2025 19:22:55 +0530 Subject: [PATCH 004/194] [FEAT] added support for AuthInfo in extra for StreamableHTTPServerTransport --- src/server/streamableHttp.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c9051073..f88842fa 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -4,6 +4,7 @@ import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCRespons import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; +import { AuthInfo } from "./auth/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -112,7 +113,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string | undefined; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; @@ -286,7 +287,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles POST requests containing JSON-RPC messages */ - private async handlePostRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { + private async handlePostRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { try { // Validate the Accept header const acceptHeader = req.headers.accept; @@ -316,6 +317,8 @@ export class StreamableHTTPServerTransport implements Transport { return; } + const authInfo: AuthInfo | undefined = req.auth; + let rawMessage; if (parsedBody !== undefined) { rawMessage = parsedBody; @@ -392,7 +395,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message); + this.onmessage?.(message, { authInfo }); } } else if (hasRequests) { // The default behavior is to use SSE streaming @@ -427,7 +430,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message); + this.onmessage?.(message, { authInfo }); } // 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 From 4b5c16fae083fd35c22c04b45a4262c502522e3e Mon Sep 17 00:00:00 2001 From: Nikhil Taneja <80670724+its-nikhil@users.noreply.github.com> Date: Thu, 24 Apr 2025 23:26:21 +0530 Subject: [PATCH 005/194] [FEAT] added unit test for StreamableHTTPServerTransport with AuthInfo --- src/server/streamableHttp.test.ts | 158 +++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 2 +- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index a0f2e0bb..b961f6c4 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -5,6 +5,7 @@ import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./ import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; +import { AuthInfo } from "./auth/types.js"; /** * Test server configuration for StreamableHTTPServerTransport tests @@ -70,6 +71,61 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: return { server, transport, mcpServer, baseUrl }; } +/** + * Helper to create and start authenticated test HTTP server with MCP setup + */ +async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: (() => randomUUID()) }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + mcpServer.tool( + "profile", + "A user profile data tool", + { active: z.boolean().describe("Profile status") }, + async ({ active }, { authInfo }): Promise => { + return { content: [{ type: "text", text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + req.auth = { token: req.headers["authorization"]?.split(" ")[1] } as AuthInfo; + await transport.handleRequest(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}`)); + }); + }); + + return { server, transport, mcpServer, baseUrl }; +} + /** * Helper to stop test server */ @@ -120,10 +176,11 @@ async function readSSEEvent(response: Response): Promise { /** * Helper to send JSON-RPC request */ -async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise { +async function sendPostRequest(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) { @@ -673,6 +730,105 @@ describe("StreamableHTTPServerTransport", () => { }); }); +describe("StreamableHTTPServerTransport with AuthInfo", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestAuthServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get("mcp-session-id"); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it("should call a tool with authInfo", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "profile", + arguments: {active: true}, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, {'authorization': 'Bearer test-token'}); + 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: "Active profile from token: test-token!", + }, + ], + }, + id: "call-1", + }); + }); + + it("should calls tool without authInfo when it is optional", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "profile", + arguments: {active: false}, + }, + 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: "Inactive profile from token: undefined!", + }, + ], + }, + id: "call-1", + }); + }); +}); + // Test JSON Response Mode describe("StreamableHTTPServerTransport with JSON Response Mode", () => { let server: Server; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index f88842fa..65180566 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -136,7 +136,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles an incoming HTTP request, whether GET or POST */ - async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { + async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "GET") { From b71e0965202ac89f5817da15ab38837f1e1b75ea Mon Sep 17 00:00:00 2001 From: Leonid Domnitser Date: Mon, 28 Apr 2025 16:26:31 -0700 Subject: [PATCH 006/194] fix: add missing eventsource-parser dependency --- package-lock.json | 5 +++-- package.json | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1165b751..9a277c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.10.2", "license": "MIT", "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/package.json b/package.json index f24053c5..9b40c2b6 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", From 30b5544e8b88fe9a46243259699645f83cab0553 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 2 May 2025 08:40:58 +0000 Subject: [PATCH 007/194] feat: Add scope parameter to startAuthorization function - Add optional scope parameter to startAuthorization function signature - Include scope in authorization URL query parameters when provided - Update auth function to pass through scope from provider.clientMetadata - Add tests to verify scope is correctly included/excluded from auth URL This enhancement allows clients to specify authorization scopes during the OAuth flow. --- src/client/auth.test.ts | 25 +++++++++++++++++++++++++ src/client/auth.ts | 9 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 629feab7..eba7074b 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -177,6 +177,31 @@ describe("OAuth Authorization", () => { expect(codeVerifier).toBe("test_verifier"); }); + it("includes scope parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read write profile", + } + ); + + expect(authorizationUrl.searchParams.get("scope")).toBe("read write profile"); + }); + + it("excludes scope parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("scope")).toBe(false); + }); + 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 a279121b..b170cefc 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -145,7 +145,8 @@ export async function auth( const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { metadata, clientInformation, - redirectUrl: provider.redirectUrl + redirectUrl: provider.redirectUrl, + scope: provider.clientMetadata.scope }); await provider.saveCodeVerifier(codeVerifier); @@ -202,10 +203,12 @@ export async function startAuthorization( metadata, clientInformation, redirectUrl, + scope, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; + scope?: string; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -246,6 +249,10 @@ export async function startAuthorization( codeChallengeMethod, ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); + + if (scope) { + authorizationUrl.searchParams.set("scope", scope); + } return { authorizationUrl, codeVerifier }; } From 52dc7f116080a143ff65a559a4f10a3420ec4d57 Mon Sep 17 00:00:00 2001 From: yuna0x0 Date: Tue, 6 May 2025 01:49:33 +0800 Subject: [PATCH 008/194] Fix isZodRawShape return false on empty object and add test --- src/server/mcp.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 7 +++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c9be5c76..5f50df68 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -622,6 +622,53 @@ describe("tool()", () => { }); }); + test("should register tool with description, empty params, and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + "A tool with everything but empty params", + {}, + { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + async () => ({ + content: [{ type: "text", text: "Test response" }] + }) + ); + + 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"); + expect(result.tools[0].description).toBe("A tool with everything but empty params"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: {} + }); + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool with empty params", + readOnlyHint: true, + openWorldHint: false + }); + }); + test("should validate tool args", async () => { const mcpServer = new McpServer({ name: "test server", diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 6204eb2a..dccfa04e 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -663,8 +663,11 @@ export class McpServer { // Helper to check if an object is a Zod schema (ZodRawShape) const isZodRawShape = (obj: unknown): obj is ZodRawShape => { if (typeof obj !== "object" || obj === null) return false; - // Check that at least one property is a ZodType instance - return Object.values(obj as object).some(v => v instanceof ZodType); + + const isEmptyObject = z.object({}).strict().safeParse(obj).success; + + // Check if object is empty or at least one property is a ZodType instance + return isEmptyObject || Object.values(obj as object).some(v => v instanceof ZodType); }; let description: string | undefined; From ec7a794f280ac4f4cbf51d16810cd1777f480fed Mon Sep 17 00:00:00 2001 From: Peter Vandenabeele Date: Mon, 5 May 2025 21:53:47 +0200 Subject: [PATCH 009/194] Fix ToC in README and markdown linting warnings --- CLAUDE.md | 7 +++++-- CODE_OF_CONDUCT.md | 8 ++++---- README.md | 14 ++++++++++---- SECURITY.md | 1 + 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e084134..272c4d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ # MCP TypeScript SDK Guide ## Build & Test Commands -``` + +```sh npm run build # Build ESM and CJS versions npm run lint # Run ESLint npm test # Run all tests @@ -10,6 +11,7 @@ npx jest -t "test name" # Run tests matching pattern ``` ## Code Style Guidelines + - **TypeScript**: Strict type checking, ES modules, explicit return types - **Naming**: PascalCase for classes/types, camelCase for functions/variables - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix @@ -20,6 +22,7 @@ npx jest -t "test name" # Run tests matching pattern - **Comments**: JSDoc for public APIs, inline comments for complex logic ## Project Structure + - `/src`: Source code with client, server, and shared modules - Tests alongside source files with `.test.ts` suffix -- Node.js >= 18 required \ No newline at end of file +- Node.js >= 18 required diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 05c32c60..985a2856 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -mcp-coc@anthropic.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/README.md b/README.md index c9102260..c5fa3162 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # MCP TypeScript SDK ![NPM Version](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fsdk) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fsdk) ## Table of Contents + - [Overview](#overview) - [Installation](#installation) -- [Quickstart](#quickstart) +- [Quickstart](#quick-start) - [What is MCP?](#what-is-mcp) - [Core Concepts](#core-concepts) - [Server](#server) @@ -18,11 +19,14 @@ - [Echo Server](#echo-server) - [SQLite Explorer](#sqlite-explorer) - [Advanced Usage](#advanced-usage) + - [Dynamic Servers](#dynamic-servers) - [Low-Level Server](#low-level-server) - [Writing MCP Clients](#writing-mcp-clients) - - [Server Capabilities](#server-capabilities) - - [Proxy OAuth Server](#proxy-authorization-requests-upstream) + - [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream) - [Backwards Compatibility](#backwards-compatibility) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) ## Overview @@ -377,6 +381,7 @@ app.listen(PORT, () => { ``` This stateless approach is useful for: + - Simple API wrappers - RESTful scenarios where each request is independent - Horizontally scaled deployments without shared session state @@ -720,6 +725,7 @@ app.use(mcpAuthRouter({ ``` This setup allows you to: + - Forward OAuth requests to an external provider - Add custom token validation logic - Manage client registrations @@ -833,7 +839,7 @@ app.listen(3000); ## Contributing -Issues and pull requests are welcome on GitHub at https://github.com/modelcontextprotocol/typescript-sdk. +Issues and pull requests are welcome on GitHub at . ## License diff --git a/SECURITY.md b/SECURITY.md index 8c09400c..65451561 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,5 @@ # Security Policy + Thank you for helping us keep the SDKs and systems they interact with secure. ## Reporting Security Issues From 59fb4845d6e49bca6b63cb12a617872682d3133a Mon Sep 17 00:00:00 2001 From: Xiaofu Huang Date: Tue, 6 May 2025 17:25:38 +0800 Subject: [PATCH 010/194] fix: Expose the MCP child process PID as an accessible property in StdioClientTransport --- src/client/stdio.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 9e35293d..4f7f5f1b 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -184,6 +184,10 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } + get pid(): number | undefined { + return this._process?.pid; + } + private processReadBuffer() { while (true) { try { From 7e134f259edbc0e0af897afc4c52154351e208d4 Mon Sep 17 00:00:00 2001 From: Xiaofu Huang Date: Tue, 6 May 2025 17:38:26 +0800 Subject: [PATCH 011/194] test: add ut of chile process pod in StdioClientTransport --- src/client/stdio.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 646f9ea5..96dd5648 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -59,3 +59,12 @@ test("should read messages", async () => { await client.close(); }); + +test("should return child process pid", async () => { + const client = new StdioClientTransport(serverParameters); + + await client.start(); + expect(client.pid).toBeDefined(); + await client.close(); + expect(client.pid).toBeUndefined(); +}); From d154914db74627bb70bbaf1842a9eb937e67a103 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 May 2025 10:13:09 +0000 Subject: [PATCH 012/194] fix: change pid return type from undefined to be null --- src/client/stdio.test.ts | 4 ++-- src/client/stdio.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 96dd5648..b2132446 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -64,7 +64,7 @@ test("should return child process pid", async () => { const client = new StdioClientTransport(serverParameters); await client.start(); - expect(client.pid).toBeDefined(); + expect(client.pid).not.toBeNull(); await client.close(); - expect(client.pid).toBeUndefined(); + expect(client.pid).toBeNull(); }); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 4f7f5f1b..af29614e 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -184,8 +184,13 @@ export class StdioClientTransport implements Transport { return this._process?.stderr ?? null; } - get pid(): number | undefined { - return this._process?.pid; + /** + * The child process pid spawned by this transport. + * + * This is only available after the transport has been started. + */ + get pid(): number | null { + return this._process?.pid ?? null; } private processReadBuffer() { From c2397b877c69a3e0427f7e0e3817165113be19f1 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Wed, 7 May 2025 03:15:14 +0800 Subject: [PATCH 013/194] docs: Fix import paths for ProxyOAuthServerProvider in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c9102260..09a0bd41 100644 --- a/README.md +++ b/README.md @@ -686,7 +686,8 @@ You can proxy OAuth requests to an external authorization provider: ```typescript import express from 'express'; -import { ProxyOAuthServerProvider, mcpAuthRouter } from '@modelcontextprotocol/sdk'; +import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; const app = express(); From 51ce07fada7d1cdedaf4ca28820a3911ae1329ec Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 8 May 2025 18:05:27 +0100 Subject: [PATCH 014/194] bump version to 1.11.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c6a65847..fa6f9545 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.0", + "version": "1.11.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b6617dc44c409c5f8c246441059d652ff61e2efa Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 9 May 2025 15:23:50 +1000 Subject: [PATCH 015/194] Fix Zod object detection logic Previously, this check was `value instanceof ZodType`, which breaks if the user is using a different version of Zod than the library. Instead, do the check using the structure of the input object rather than its prototype chain. Additionally, this adds a vendored version of Zod to use for tests, which ensures that regressions like this would be caught next time. --- .../__tests__/vendor/zod@3.24.4.umd.d.ts | 5 ++++ src/server/__tests__/vendor/zod@3.24.4.umd.js | 8 +++++ src/server/mcp.test.ts | 4 ++- src/server/mcp.ts | 29 ++++++++++++------- 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/server/__tests__/vendor/zod@3.24.4.umd.d.ts create mode 100644 src/server/__tests__/vendor/zod@3.24.4.umd.js diff --git a/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts b/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts new file mode 100644 index 00000000..890bdb5c --- /dev/null +++ b/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts @@ -0,0 +1,5 @@ +import * as _zod from 'zod'; + +// This makes the vendored zod use the same types as the actual zod package +export declare const z: typeof _zod.z; +export default _zod.default; \ No newline at end of file diff --git a/src/server/__tests__/vendor/zod@3.24.4.umd.js b/src/server/__tests__/vendor/zod@3.24.4.umd.js new file mode 100644 index 00000000..a4e9e545 --- /dev/null +++ b/src/server/__tests__/vendor/zod@3.24.4.umd.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.39.0. + * Original file: /npm/zod@3.24.4/lib/index.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Zod={})}(this,(function(e){"use strict";var t;e.util=void 0,(t=e.util||(e.util={})).assertEqual=e=>e,t.assertIs=function(e){},t.assertNever=function(e){throw new Error},t.arrayToEnum=e=>{const t={};for(const a of e)t[a]=a;return t},t.getValidEnumValues=e=>{const a=t.objectKeys(e).filter((t=>"number"!=typeof e[e[t]])),s={};for(const t of a)s[t]=e[t];return t.objectValues(s)},t.objectValues=e=>t.objectKeys(e).map((function(t){return e[t]})),t.objectKeys="function"==typeof Object.keys?e=>Object.keys(e):e=>{const t=[];for(const a in e)Object.prototype.hasOwnProperty.call(e,a)&&t.push(a);return t},t.find=(e,t)=>{for(const a of e)if(t(a))return a},t.isInteger="function"==typeof Number.isInteger?e=>Number.isInteger(e):e=>"number"==typeof e&&isFinite(e)&&Math.floor(e)===e,t.joinValues=function(e,t=" | "){return e.map((e=>"string"==typeof e?`'${e}'`:e)).join(t)},t.jsonStringifyReplacer=(e,t)=>"bigint"==typeof t?t.toString():t,e.objectUtil=void 0,(e.objectUtil||(e.objectUtil={})).mergeShapes=(e,t)=>({...e,...t});const a=e.util.arrayToEnum(["string","nan","number","integer","float","boolean","date","bigint","symbol","function","undefined","null","array","object","unknown","promise","void","never","map","set"]),s=e=>{switch(typeof e){case"undefined":return a.undefined;case"string":return a.string;case"number":return isNaN(e)?a.nan:a.number;case"boolean":return a.boolean;case"function":return a.function;case"bigint":return a.bigint;case"symbol":return a.symbol;case"object":return Array.isArray(e)?a.array:null===e?a.null:e.then&&"function"==typeof e.then&&e.catch&&"function"==typeof e.catch?a.promise:"undefined"!=typeof Map&&e instanceof Map?a.map:"undefined"!=typeof Set&&e instanceof Set?a.set:"undefined"!=typeof Date&&e instanceof Date?a.date:a.object;default:return a.unknown}},r=e.util.arrayToEnum(["invalid_type","invalid_literal","custom","invalid_union","invalid_union_discriminator","invalid_enum_value","unrecognized_keys","invalid_arguments","invalid_return_type","invalid_date","invalid_string","too_small","too_big","invalid_intersection_types","not_multiple_of","not_finite"]),n=e=>JSON.stringify(e,null,2).replace(/"([^"]+)":/g,"$1:");class i extends Error{get errors(){return this.issues}constructor(e){super(),this.issues=[],this.addIssue=e=>{this.issues=[...this.issues,e]},this.addIssues=(e=[])=>{this.issues=[...this.issues,...e]};const t=new.target.prototype;Object.setPrototypeOf?Object.setPrototypeOf(this,t):this.__proto__=t,this.name="ZodError",this.issues=e}format(e){const t=e||function(e){return e.message},a={_errors:[]},s=e=>{for(const r of e.issues)if("invalid_union"===r.code)r.unionErrors.map(s);else if("invalid_return_type"===r.code)s(r.returnTypeError);else if("invalid_arguments"===r.code)s(r.argumentsError);else if(0===r.path.length)a._errors.push(t(r));else{let e=a,s=0;for(;se.message){const t={},a=[];for(const s of this.issues)s.path.length>0?(t[s.path[0]]=t[s.path[0]]||[],t[s.path[0]].push(e(s))):a.push(e(s));return{formErrors:a,fieldErrors:t}}get formErrors(){return this.flatten()}}i.create=e=>new i(e);const o=(t,s)=>{let n;switch(t.code){case r.invalid_type:n=t.received===a.undefined?"Required":`Expected ${t.expected}, received ${t.received}`;break;case r.invalid_literal:n=`Invalid literal value, expected ${JSON.stringify(t.expected,e.util.jsonStringifyReplacer)}`;break;case r.unrecognized_keys:n=`Unrecognized key(s) in object: ${e.util.joinValues(t.keys,", ")}`;break;case r.invalid_union:n="Invalid input";break;case r.invalid_union_discriminator:n=`Invalid discriminator value. Expected ${e.util.joinValues(t.options)}`;break;case r.invalid_enum_value:n=`Invalid enum value. Expected ${e.util.joinValues(t.options)}, received '${t.received}'`;break;case r.invalid_arguments:n="Invalid function arguments";break;case r.invalid_return_type:n="Invalid function return type";break;case r.invalid_date:n="Invalid date";break;case r.invalid_string:"object"==typeof t.validation?"includes"in t.validation?(n=`Invalid input: must include "${t.validation.includes}"`,"number"==typeof t.validation.position&&(n=`${n} at one or more positions greater than or equal to ${t.validation.position}`)):"startsWith"in t.validation?n=`Invalid input: must start with "${t.validation.startsWith}"`:"endsWith"in t.validation?n=`Invalid input: must end with "${t.validation.endsWith}"`:e.util.assertNever(t.validation):n="regex"!==t.validation?`Invalid ${t.validation}`:"Invalid";break;case r.too_small:n="array"===t.type?`Array must contain ${t.exact?"exactly":t.inclusive?"at least":"more than"} ${t.minimum} element(s)`:"string"===t.type?`String must contain ${t.exact?"exactly":t.inclusive?"at least":"over"} ${t.minimum} character(s)`:"number"===t.type?`Number must be ${t.exact?"exactly equal to ":t.inclusive?"greater than or equal to ":"greater than "}${t.minimum}`:"date"===t.type?`Date must be ${t.exact?"exactly equal to ":t.inclusive?"greater than or equal to ":"greater than "}${new Date(Number(t.minimum))}`:"Invalid input";break;case r.too_big:n="array"===t.type?`Array must contain ${t.exact?"exactly":t.inclusive?"at most":"less than"} ${t.maximum} element(s)`:"string"===t.type?`String must contain ${t.exact?"exactly":t.inclusive?"at most":"under"} ${t.maximum} character(s)`:"number"===t.type?`Number must be ${t.exact?"exactly":t.inclusive?"less than or equal to":"less than"} ${t.maximum}`:"bigint"===t.type?`BigInt must be ${t.exact?"exactly":t.inclusive?"less than or equal to":"less than"} ${t.maximum}`:"date"===t.type?`Date must be ${t.exact?"exactly":t.inclusive?"smaller than or equal to":"smaller than"} ${new Date(Number(t.maximum))}`:"Invalid input";break;case r.custom:n="Invalid input";break;case r.invalid_intersection_types:n="Intersection results could not be merged";break;case r.not_multiple_of:n=`Number must be a multiple of ${t.multipleOf}`;break;case r.not_finite:n="Number must be finite";break;default:n=s.defaultError,e.util.assertNever(t)}return{message:n}};let d=o;function u(e){d=e}function c(){return d}const l=e=>{const{data:t,path:a,errorMaps:s,issueData:r}=e,n=[...a,...r.path||[]],i={...r,path:n};if(void 0!==r.message)return{...r,path:n,message:r.message};let o="";const d=s.filter((e=>!!e)).slice().reverse();for(const e of d)o=e(i,{data:t,defaultError:o}).message;return{...r,path:n,message:o}},p=[];function h(e,t){const a=c(),s=l({issueData:t,data:e.data,path:e.path,errorMaps:[e.common.contextualErrorMap,e.schemaErrorMap,a,a===o?void 0:o].filter((e=>!!e))});e.common.issues.push(s)}class m{constructor(){this.value="valid"}dirty(){"valid"===this.value&&(this.value="dirty")}abort(){"aborted"!==this.value&&(this.value="aborted")}static mergeArray(e,t){const a=[];for(const s of t){if("aborted"===s.status)return f;"dirty"===s.status&&e.dirty(),a.push(s.value)}return{status:e.value,value:a}}static async mergeObjectAsync(e,t){const a=[];for(const e of t){const t=await e.key,s=await e.value;a.push({key:t,value:s})}return m.mergeObjectSync(e,a)}static mergeObjectSync(e,t){const a={};for(const s of t){const{key:t,value:r}=s;if("aborted"===t.status)return f;if("aborted"===r.status)return f;"dirty"===t.status&&e.dirty(),"dirty"===r.status&&e.dirty(),"__proto__"===t.value||void 0===r.value&&!s.alwaysSet||(a[t.value]=r.value)}return{status:e.value,value:a}}}const f=Object.freeze({status:"aborted"}),y=e=>({status:"dirty",value:e}),v=e=>({status:"valid",value:e}),_=e=>"aborted"===e.status,g=e=>"dirty"===e.status,b=e=>"valid"===e.status,k=e=>"undefined"!=typeof Promise&&e instanceof Promise;function x(e,t,a,s){if("a"===a&&!s)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof t?e!==t||!s:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===a?s:"a"===a?s.call(e):s?s.value:t.get(e)}function Z(e,t,a,s,r){if("m"===s)throw new TypeError("Private method is not writable");if("a"===s&&!r)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof t?e!==t||!r:!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return"a"===s?r.call(e,a):r?r.value=a:t.set(e,a),a}var w,T,P;"function"==typeof SuppressedError&&SuppressedError,function(e){e.errToObj=e=>"string"==typeof e?{message:e}:e||{},e.toString=e=>"string"==typeof e?e:null==e?void 0:e.message}(w||(w={}));class O{constructor(e,t,a,s){this._cachedPath=[],this.parent=e,this.data=t,this._path=a,this._key=s}get path(){return this._cachedPath.length||(this._key instanceof Array?this._cachedPath.push(...this._path,...this._key):this._cachedPath.push(...this._path,this._key)),this._cachedPath}}const C=(e,t)=>{if(b(t))return{success:!0,data:t.value};if(!e.common.issues.length)throw new Error("Validation failed but no issues detected.");return{success:!1,get error(){if(this._error)return this._error;const t=new i(e.common.issues);return this._error=t,this._error}}};function N(e){if(!e)return{};const{errorMap:t,invalid_type_error:a,required_error:s,description:r}=e;if(t&&(a||s))throw new Error('Can\'t use "invalid_type_error" or "required_error" in conjunction with custom error map.');if(t)return{errorMap:t,description:r};return{errorMap:(t,r)=>{var n,i;const{message:o}=e;return"invalid_enum_value"===t.code?{message:null!=o?o:r.defaultError}:void 0===r.data?{message:null!==(n=null!=o?o:s)&&void 0!==n?n:r.defaultError}:"invalid_type"!==t.code?{message:r.defaultError}:{message:null!==(i=null!=o?o:a)&&void 0!==i?i:r.defaultError}},description:r}}class A{get description(){return this._def.description}_getType(e){return s(e.data)}_getOrReturnCtx(e,t){return t||{common:e.parent.common,data:e.data,parsedType:s(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}_processInputParams(e){return{status:new m,ctx:{common:e.parent.common,data:e.data,parsedType:s(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}}_parseSync(e){const t=this._parse(e);if(k(t))throw new Error("Synchronous parse encountered promise.");return t}_parseAsync(e){const t=this._parse(e);return Promise.resolve(t)}parse(e,t){const a=this.safeParse(e,t);if(a.success)return a.data;throw a.error}safeParse(e,t){var a;const r={common:{issues:[],async:null!==(a=null==t?void 0:t.async)&&void 0!==a&&a,contextualErrorMap:null==t?void 0:t.errorMap},path:(null==t?void 0:t.path)||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)},n=this._parseSync({data:e,path:r.path,parent:r});return C(r,n)}"~validate"(e){var t,a;const r={common:{issues:[],async:!!this["~standard"].async},path:[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)};if(!this["~standard"].async)try{const t=this._parseSync({data:e,path:[],parent:r});return b(t)?{value:t.value}:{issues:r.common.issues}}catch(e){(null===(a=null===(t=null==e?void 0:e.message)||void 0===t?void 0:t.toLowerCase())||void 0===a?void 0:a.includes("encountered"))&&(this["~standard"].async=!0),r.common={issues:[],async:!0}}return this._parseAsync({data:e,path:[],parent:r}).then((e=>b(e)?{value:e.value}:{issues:r.common.issues}))}async parseAsync(e,t){const a=await this.safeParseAsync(e,t);if(a.success)return a.data;throw a.error}async safeParseAsync(e,t){const a={common:{issues:[],contextualErrorMap:null==t?void 0:t.errorMap,async:!0},path:(null==t?void 0:t.path)||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)},r=this._parse({data:e,path:a.path,parent:a}),n=await(k(r)?r:Promise.resolve(r));return C(a,n)}refine(e,t){const a=e=>"string"==typeof t||void 0===t?{message:t}:"function"==typeof t?t(e):t;return this._refinement(((t,s)=>{const n=e(t),i=()=>s.addIssue({code:r.custom,...a(t)});return"undefined"!=typeof Promise&&n instanceof Promise?n.then((e=>!!e||(i(),!1))):!!n||(i(),!1)}))}refinement(e,t){return this._refinement(((a,s)=>!!e(a)||(s.addIssue("function"==typeof t?t(a,s):t),!1)))}_refinement(t){return new Ae({schema:this,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:{type:"refinement",refinement:t}})}superRefine(e){return this._refinement(e)}constructor(e){this.spa=this.safeParseAsync,this._def=e,this.parse=this.parse.bind(this),this.safeParse=this.safeParse.bind(this),this.parseAsync=this.parseAsync.bind(this),this.safeParseAsync=this.safeParseAsync.bind(this),this.spa=this.spa.bind(this),this.refine=this.refine.bind(this),this.refinement=this.refinement.bind(this),this.superRefine=this.superRefine.bind(this),this.optional=this.optional.bind(this),this.nullable=this.nullable.bind(this),this.nullish=this.nullish.bind(this),this.array=this.array.bind(this),this.promise=this.promise.bind(this),this.or=this.or.bind(this),this.and=this.and.bind(this),this.transform=this.transform.bind(this),this.brand=this.brand.bind(this),this.default=this.default.bind(this),this.catch=this.catch.bind(this),this.describe=this.describe.bind(this),this.pipe=this.pipe.bind(this),this.readonly=this.readonly.bind(this),this.isNullable=this.isNullable.bind(this),this.isOptional=this.isOptional.bind(this),this["~standard"]={version:1,vendor:"zod",validate:e=>this["~validate"](e)}}optional(){return Se.create(this,this._def)}nullable(){return je.create(this,this._def)}nullish(){return this.nullable().optional()}array(){return le.create(this)}promise(){return Ne.create(this,this._def)}or(e){return me.create([this,e],this._def)}and(e){return _e.create(this,e,this._def)}transform(t){return new Ae({...N(this._def),schema:this,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:{type:"transform",transform:t}})}default(t){const a="function"==typeof t?t:()=>t;return new Ee({...N(this._def),innerType:this,defaultValue:a,typeName:e.ZodFirstPartyTypeKind.ZodDefault})}brand(){return new Ke({typeName:e.ZodFirstPartyTypeKind.ZodBranded,type:this,...N(this._def)})}catch(t){const a="function"==typeof t?t:()=>t;return new Fe({...N(this._def),innerType:this,catchValue:a,typeName:e.ZodFirstPartyTypeKind.ZodCatch})}describe(e){return new(0,this.constructor)({...this._def,description:e})}pipe(e){return $e.create(this,e)}readonly(){return Me.create(this)}isOptional(){return this.safeParse(void 0).success}isNullable(){return this.safeParse(null).success}}const S=/^c[^\s-]{8,}$/i,j=/^[0-9a-z]+$/,E=/^[0-9A-HJKMNP-TV-Z]{26}$/i,F=/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i,I=/^[a-z0-9_-]{21}$/i,R=/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/,K=/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/,$=/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;let M;const D=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,L=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,z=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/,V=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,U=/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,B=/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,W="((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))",q=new RegExp(`^${W}$`);function J(e){let t="[0-5]\\d";e.precision?t=`${t}\\.\\d{${e.precision}}`:null==e.precision&&(t=`${t}(\\.\\d+)?`);return`([01]\\d|2[0-3]):[0-5]\\d(:${t})${e.precision?"+":"?"}`}function Y(e){let t=`${W}T${J(e)}`;const a=[];return a.push(e.local?"Z?":"Z"),e.offset&&a.push("([+-]\\d{2}:?\\d{2})"),t=`${t}(${a.join("|")})`,new RegExp(`^${t}$`)}function H(e,t){if(!R.test(e))return!1;try{const[a]=e.split("."),s=a.replace(/-/g,"+").replace(/_/g,"/").padEnd(a.length+(4-a.length%4)%4,"="),r=JSON.parse(atob(s));return"object"==typeof r&&null!==r&&(!(!r.typ||!r.alg)&&(!t||r.alg===t))}catch(e){return!1}}function G(e,t){return!("v4"!==t&&t||!L.test(e))||!("v6"!==t&&t||!V.test(e))}class X extends A{_parse(t){this._def.coerce&&(t.data=String(t.data));if(this._getType(t)!==a.string){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.string,received:e.parsedType}),f}const s=new m;let n;for(const a of this._def.checks)if("min"===a.kind)t.data.lengtha.value&&(n=this._getOrReturnCtx(t,n),h(n,{code:r.too_big,maximum:a.value,type:"string",inclusive:!0,exact:!1,message:a.message}),s.dirty());else if("length"===a.kind){const e=t.data.length>a.value,i=t.data.lengthe.test(t)),{validation:t,code:r.invalid_string,...w.errToObj(a)})}_addCheck(e){return new X({...this._def,checks:[...this._def.checks,e]})}email(e){return this._addCheck({kind:"email",...w.errToObj(e)})}url(e){return this._addCheck({kind:"url",...w.errToObj(e)})}emoji(e){return this._addCheck({kind:"emoji",...w.errToObj(e)})}uuid(e){return this._addCheck({kind:"uuid",...w.errToObj(e)})}nanoid(e){return this._addCheck({kind:"nanoid",...w.errToObj(e)})}cuid(e){return this._addCheck({kind:"cuid",...w.errToObj(e)})}cuid2(e){return this._addCheck({kind:"cuid2",...w.errToObj(e)})}ulid(e){return this._addCheck({kind:"ulid",...w.errToObj(e)})}base64(e){return this._addCheck({kind:"base64",...w.errToObj(e)})}base64url(e){return this._addCheck({kind:"base64url",...w.errToObj(e)})}jwt(e){return this._addCheck({kind:"jwt",...w.errToObj(e)})}ip(e){return this._addCheck({kind:"ip",...w.errToObj(e)})}cidr(e){return this._addCheck({kind:"cidr",...w.errToObj(e)})}datetime(e){var t,a;return"string"==typeof e?this._addCheck({kind:"datetime",precision:null,offset:!1,local:!1,message:e}):this._addCheck({kind:"datetime",precision:void 0===(null==e?void 0:e.precision)?null:null==e?void 0:e.precision,offset:null!==(t=null==e?void 0:e.offset)&&void 0!==t&&t,local:null!==(a=null==e?void 0:e.local)&&void 0!==a&&a,...w.errToObj(null==e?void 0:e.message)})}date(e){return this._addCheck({kind:"date",message:e})}time(e){return"string"==typeof e?this._addCheck({kind:"time",precision:null,message:e}):this._addCheck({kind:"time",precision:void 0===(null==e?void 0:e.precision)?null:null==e?void 0:e.precision,...w.errToObj(null==e?void 0:e.message)})}duration(e){return this._addCheck({kind:"duration",...w.errToObj(e)})}regex(e,t){return this._addCheck({kind:"regex",regex:e,...w.errToObj(t)})}includes(e,t){return this._addCheck({kind:"includes",value:e,position:null==t?void 0:t.position,...w.errToObj(null==t?void 0:t.message)})}startsWith(e,t){return this._addCheck({kind:"startsWith",value:e,...w.errToObj(t)})}endsWith(e,t){return this._addCheck({kind:"endsWith",value:e,...w.errToObj(t)})}min(e,t){return this._addCheck({kind:"min",value:e,...w.errToObj(t)})}max(e,t){return this._addCheck({kind:"max",value:e,...w.errToObj(t)})}length(e,t){return this._addCheck({kind:"length",value:e,...w.errToObj(t)})}nonempty(e){return this.min(1,w.errToObj(e))}trim(){return new X({...this._def,checks:[...this._def.checks,{kind:"trim"}]})}toLowerCase(){return new X({...this._def,checks:[...this._def.checks,{kind:"toLowerCase"}]})}toUpperCase(){return new X({...this._def,checks:[...this._def.checks,{kind:"toUpperCase"}]})}get isDatetime(){return!!this._def.checks.find((e=>"datetime"===e.kind))}get isDate(){return!!this._def.checks.find((e=>"date"===e.kind))}get isTime(){return!!this._def.checks.find((e=>"time"===e.kind))}get isDuration(){return!!this._def.checks.find((e=>"duration"===e.kind))}get isEmail(){return!!this._def.checks.find((e=>"email"===e.kind))}get isURL(){return!!this._def.checks.find((e=>"url"===e.kind))}get isEmoji(){return!!this._def.checks.find((e=>"emoji"===e.kind))}get isUUID(){return!!this._def.checks.find((e=>"uuid"===e.kind))}get isNANOID(){return!!this._def.checks.find((e=>"nanoid"===e.kind))}get isCUID(){return!!this._def.checks.find((e=>"cuid"===e.kind))}get isCUID2(){return!!this._def.checks.find((e=>"cuid2"===e.kind))}get isULID(){return!!this._def.checks.find((e=>"ulid"===e.kind))}get isIP(){return!!this._def.checks.find((e=>"ip"===e.kind))}get isCIDR(){return!!this._def.checks.find((e=>"cidr"===e.kind))}get isBase64(){return!!this._def.checks.find((e=>"base64"===e.kind))}get isBase64url(){return!!this._def.checks.find((e=>"base64url"===e.kind))}get minLength(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxLength(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.values?a:s;return parseInt(e.toFixed(r).replace(".",""))%parseInt(t.toFixed(r).replace(".",""))/Math.pow(10,r)}X.create=t=>{var a;return new X({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodString,coerce:null!==(a=null==t?void 0:t.coerce)&&void 0!==a&&a,...N(t)})};class ee extends A{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte,this.step=this.multipleOf}_parse(t){this._def.coerce&&(t.data=Number(t.data));if(this._getType(t)!==a.number){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.number,received:e.parsedType}),f}let s;const n=new m;for(const a of this._def.checks)if("int"===a.kind)e.util.isInteger(t.data)||(s=this._getOrReturnCtx(t,s),h(s,{code:r.invalid_type,expected:"integer",received:"float",message:a.message}),n.dirty());else if("min"===a.kind){(a.inclusive?t.dataa.value:t.data>=a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.too_big,maximum:a.value,type:"number",inclusive:a.inclusive,exact:!1,message:a.message}),n.dirty())}else"multipleOf"===a.kind?0!==Q(t.data,a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):"finite"===a.kind?Number.isFinite(t.data)||(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_finite,message:a.message}),n.dirty()):e.util.assertNever(a);return{status:n.value,value:t.data}}gte(e,t){return this.setLimit("min",e,!0,w.toString(t))}gt(e,t){return this.setLimit("min",e,!1,w.toString(t))}lte(e,t){return this.setLimit("max",e,!0,w.toString(t))}lt(e,t){return this.setLimit("max",e,!1,w.toString(t))}setLimit(e,t,a,s){return new ee({...this._def,checks:[...this._def.checks,{kind:e,value:t,inclusive:a,message:w.toString(s)}]})}_addCheck(e){return new ee({...this._def,checks:[...this._def.checks,e]})}int(e){return this._addCheck({kind:"int",message:w.toString(e)})}positive(e){return this._addCheck({kind:"min",value:0,inclusive:!1,message:w.toString(e)})}negative(e){return this._addCheck({kind:"max",value:0,inclusive:!1,message:w.toString(e)})}nonpositive(e){return this._addCheck({kind:"max",value:0,inclusive:!0,message:w.toString(e)})}nonnegative(e){return this._addCheck({kind:"min",value:0,inclusive:!0,message:w.toString(e)})}multipleOf(e,t){return this._addCheck({kind:"multipleOf",value:e,message:w.toString(t)})}finite(e){return this._addCheck({kind:"finite",message:w.toString(e)})}safe(e){return this._addCheck({kind:"min",inclusive:!0,value:Number.MIN_SAFE_INTEGER,message:w.toString(e)})._addCheck({kind:"max",inclusive:!0,value:Number.MAX_SAFE_INTEGER,message:w.toString(e)})}get minValue(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxValue(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.value"int"===t.kind||"multipleOf"===t.kind&&e.util.isInteger(t.value)))}get isFinite(){let e=null,t=null;for(const a of this._def.checks){if("finite"===a.kind||"int"===a.kind||"multipleOf"===a.kind)return!0;"min"===a.kind?(null===t||a.value>t)&&(t=a.value):"max"===a.kind&&(null===e||a.valuenew ee({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodNumber,coerce:(null==t?void 0:t.coerce)||!1,...N(t)});class te extends A{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte}_parse(t){if(this._def.coerce)try{t.data=BigInt(t.data)}catch(e){return this._getInvalidInput(t)}if(this._getType(t)!==a.bigint)return this._getInvalidInput(t);let s;const n=new m;for(const a of this._def.checks)if("min"===a.kind){(a.inclusive?t.dataa.value:t.data>=a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.too_big,type:"bigint",maximum:a.value,inclusive:a.inclusive,message:a.message}),n.dirty())}else"multipleOf"===a.kind?t.data%a.value!==BigInt(0)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):e.util.assertNever(a);return{status:n.value,value:t.data}}_getInvalidInput(e){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.bigint,received:t.parsedType}),f}gte(e,t){return this.setLimit("min",e,!0,w.toString(t))}gt(e,t){return this.setLimit("min",e,!1,w.toString(t))}lte(e,t){return this.setLimit("max",e,!0,w.toString(t))}lt(e,t){return this.setLimit("max",e,!1,w.toString(t))}setLimit(e,t,a,s){return new te({...this._def,checks:[...this._def.checks,{kind:e,value:t,inclusive:a,message:w.toString(s)}]})}_addCheck(e){return new te({...this._def,checks:[...this._def.checks,e]})}positive(e){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!1,message:w.toString(e)})}negative(e){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!1,message:w.toString(e)})}nonpositive(e){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!0,message:w.toString(e)})}nonnegative(e){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!0,message:w.toString(e)})}multipleOf(e,t){return this._addCheck({kind:"multipleOf",value:e,message:w.toString(t)})}get minValue(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxValue(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.value{var a;return new te({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodBigInt,coerce:null!==(a=null==t?void 0:t.coerce)&&void 0!==a&&a,...N(t)})};class ae extends A{_parse(e){this._def.coerce&&(e.data=Boolean(e.data));if(this._getType(e)!==a.boolean){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.boolean,received:t.parsedType}),f}return v(e.data)}}ae.create=t=>new ae({typeName:e.ZodFirstPartyTypeKind.ZodBoolean,coerce:(null==t?void 0:t.coerce)||!1,...N(t)});class se extends A{_parse(t){this._def.coerce&&(t.data=new Date(t.data));if(this._getType(t)!==a.date){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.date,received:e.parsedType}),f}if(isNaN(t.data.getTime())){return h(this._getOrReturnCtx(t),{code:r.invalid_date}),f}const s=new m;let n;for(const a of this._def.checks)"min"===a.kind?t.data.getTime()a.value&&(n=this._getOrReturnCtx(t,n),h(n,{code:r.too_big,message:a.message,inclusive:!0,exact:!1,maximum:a.value,type:"date"}),s.dirty()):e.util.assertNever(a);return{status:s.value,value:new Date(t.data.getTime())}}_addCheck(e){return new se({...this._def,checks:[...this._def.checks,e]})}min(e,t){return this._addCheck({kind:"min",value:e.getTime(),message:w.toString(t)})}max(e,t){return this._addCheck({kind:"max",value:e.getTime(),message:w.toString(t)})}get minDate(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return null!=e?new Date(e):null}get maxDate(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.valuenew se({checks:[],coerce:(null==t?void 0:t.coerce)||!1,typeName:e.ZodFirstPartyTypeKind.ZodDate,...N(t)});class re extends A{_parse(e){if(this._getType(e)!==a.symbol){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.symbol,received:t.parsedType}),f}return v(e.data)}}re.create=t=>new re({typeName:e.ZodFirstPartyTypeKind.ZodSymbol,...N(t)});class ne extends A{_parse(e){if(this._getType(e)!==a.undefined){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.undefined,received:t.parsedType}),f}return v(e.data)}}ne.create=t=>new ne({typeName:e.ZodFirstPartyTypeKind.ZodUndefined,...N(t)});class ie extends A{_parse(e){if(this._getType(e)!==a.null){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.null,received:t.parsedType}),f}return v(e.data)}}ie.create=t=>new ie({typeName:e.ZodFirstPartyTypeKind.ZodNull,...N(t)});class oe extends A{constructor(){super(...arguments),this._any=!0}_parse(e){return v(e.data)}}oe.create=t=>new oe({typeName:e.ZodFirstPartyTypeKind.ZodAny,...N(t)});class de extends A{constructor(){super(...arguments),this._unknown=!0}_parse(e){return v(e.data)}}de.create=t=>new de({typeName:e.ZodFirstPartyTypeKind.ZodUnknown,...N(t)});class ue extends A{_parse(e){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.never,received:t.parsedType}),f}}ue.create=t=>new ue({typeName:e.ZodFirstPartyTypeKind.ZodNever,...N(t)});class ce extends A{_parse(e){if(this._getType(e)!==a.undefined){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.void,received:t.parsedType}),f}return v(e.data)}}ce.create=t=>new ce({typeName:e.ZodFirstPartyTypeKind.ZodVoid,...N(t)});class le extends A{_parse(e){const{ctx:t,status:s}=this._processInputParams(e),n=this._def;if(t.parsedType!==a.array)return h(t,{code:r.invalid_type,expected:a.array,received:t.parsedType}),f;if(null!==n.exactLength){const e=t.data.length>n.exactLength.value,a=t.data.lengthn.maxLength.value&&(h(t,{code:r.too_big,maximum:n.maxLength.value,type:"array",inclusive:!0,exact:!1,message:n.maxLength.message}),s.dirty()),t.common.async)return Promise.all([...t.data].map(((e,a)=>n.type._parseAsync(new O(t,e,t.path,a))))).then((e=>m.mergeArray(s,e)));const i=[...t.data].map(((e,a)=>n.type._parseSync(new O(t,e,t.path,a))));return m.mergeArray(s,i)}get element(){return this._def.type}min(e,t){return new le({...this._def,minLength:{value:e,message:w.toString(t)}})}max(e,t){return new le({...this._def,maxLength:{value:e,message:w.toString(t)}})}length(e,t){return new le({...this._def,exactLength:{value:e,message:w.toString(t)}})}nonempty(e){return this.min(1,e)}}function pe(e){if(e instanceof he){const t={};for(const a in e.shape){const s=e.shape[a];t[a]=Se.create(pe(s))}return new he({...e._def,shape:()=>t})}return e instanceof le?new le({...e._def,type:pe(e.element)}):e instanceof Se?Se.create(pe(e.unwrap())):e instanceof je?je.create(pe(e.unwrap())):e instanceof ge?ge.create(e.items.map((e=>pe(e)))):e}le.create=(t,a)=>new le({type:t,minLength:null,maxLength:null,exactLength:null,typeName:e.ZodFirstPartyTypeKind.ZodArray,...N(a)});class he extends A{constructor(){super(...arguments),this._cached=null,this.nonstrict=this.passthrough,this.augment=this.extend}_getCached(){if(null!==this._cached)return this._cached;const t=this._def.shape(),a=e.util.objectKeys(t);return this._cached={shape:t,keys:a}}_parse(e){if(this._getType(e)!==a.object){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.object,received:t.parsedType}),f}const{status:t,ctx:s}=this._processInputParams(e),{shape:n,keys:i}=this._getCached(),o=[];if(!(this._def.catchall instanceof ue&&"strip"===this._def.unknownKeys))for(const e in s.data)i.includes(e)||o.push(e);const d=[];for(const e of i){const t=n[e],a=s.data[e];d.push({key:{status:"valid",value:e},value:t._parse(new O(s,a,s.path,e)),alwaysSet:e in s.data})}if(this._def.catchall instanceof ue){const e=this._def.unknownKeys;if("passthrough"===e)for(const e of o)d.push({key:{status:"valid",value:e},value:{status:"valid",value:s.data[e]}});else if("strict"===e)o.length>0&&(h(s,{code:r.unrecognized_keys,keys:o}),t.dirty());else if("strip"!==e)throw new Error("Internal ZodObject error: invalid unknownKeys value.")}else{const e=this._def.catchall;for(const t of o){const a=s.data[t];d.push({key:{status:"valid",value:t},value:e._parse(new O(s,a,s.path,t)),alwaysSet:t in s.data})}}return s.common.async?Promise.resolve().then((async()=>{const e=[];for(const t of d){const a=await t.key,s=await t.value;e.push({key:a,value:s,alwaysSet:t.alwaysSet})}return e})).then((e=>m.mergeObjectSync(t,e))):m.mergeObjectSync(t,d)}get shape(){return this._def.shape()}strict(e){return w.errToObj,new he({...this._def,unknownKeys:"strict",...void 0!==e?{errorMap:(t,a)=>{var s,r,n,i;const o=null!==(n=null===(r=(s=this._def).errorMap)||void 0===r?void 0:r.call(s,t,a).message)&&void 0!==n?n:a.defaultError;return"unrecognized_keys"===t.code?{message:null!==(i=w.errToObj(e).message)&&void 0!==i?i:o}:{message:o}}}:{}})}strip(){return new he({...this._def,unknownKeys:"strip"})}passthrough(){return new he({...this._def,unknownKeys:"passthrough"})}extend(e){return new he({...this._def,shape:()=>({...this._def.shape(),...e})})}merge(t){return new he({unknownKeys:t._def.unknownKeys,catchall:t._def.catchall,shape:()=>({...this._def.shape(),...t._def.shape()}),typeName:e.ZodFirstPartyTypeKind.ZodObject})}setKey(e,t){return this.augment({[e]:t})}catchall(e){return new he({...this._def,catchall:e})}pick(t){const a={};return e.util.objectKeys(t).forEach((e=>{t[e]&&this.shape[e]&&(a[e]=this.shape[e])})),new he({...this._def,shape:()=>a})}omit(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{t[e]||(a[e]=this.shape[e])})),new he({...this._def,shape:()=>a})}deepPartial(){return pe(this)}partial(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{const s=this.shape[e];t&&!t[e]?a[e]=s:a[e]=s.optional()})),new he({...this._def,shape:()=>a})}required(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{if(t&&!t[e])a[e]=this.shape[e];else{let t=this.shape[e];for(;t instanceof Se;)t=t._def.innerType;a[e]=t}})),new he({...this._def,shape:()=>a})}keyof(){return Pe(e.util.objectKeys(this.shape))}}he.create=(t,a)=>new he({shape:()=>t,unknownKeys:"strip",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)}),he.strictCreate=(t,a)=>new he({shape:()=>t,unknownKeys:"strict",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)}),he.lazycreate=(t,a)=>new he({shape:t,unknownKeys:"strip",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)});class me extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a=this._def.options;if(t.common.async)return Promise.all(a.map((async e=>{const a={...t,common:{...t.common,issues:[]},parent:null};return{result:await e._parseAsync({data:t.data,path:t.path,parent:a}),ctx:a}}))).then((function(e){for(const t of e)if("valid"===t.result.status)return t.result;for(const a of e)if("dirty"===a.result.status)return t.common.issues.push(...a.ctx.common.issues),a.result;const a=e.map((e=>new i(e.ctx.common.issues)));return h(t,{code:r.invalid_union,unionErrors:a}),f}));{let e;const s=[];for(const r of a){const a={...t,common:{...t.common,issues:[]},parent:null},n=r._parseSync({data:t.data,path:t.path,parent:a});if("valid"===n.status)return n;"dirty"!==n.status||e||(e={result:n,ctx:a}),a.common.issues.length&&s.push(a.common.issues)}if(e)return t.common.issues.push(...e.ctx.common.issues),e.result;const n=s.map((e=>new i(e)));return h(t,{code:r.invalid_union,unionErrors:n}),f}}get options(){return this._def.options}}me.create=(t,a)=>new me({options:t,typeName:e.ZodFirstPartyTypeKind.ZodUnion,...N(a)});const fe=t=>t instanceof we?fe(t.schema):t instanceof Ae?fe(t.innerType()):t instanceof Te?[t.value]:t instanceof Oe?t.options:t instanceof Ce?e.util.objectValues(t.enum):t instanceof Ee?fe(t._def.innerType):t instanceof ne?[void 0]:t instanceof ie?[null]:t instanceof Se?[void 0,...fe(t.unwrap())]:t instanceof je?[null,...fe(t.unwrap())]:t instanceof Ke||t instanceof Me?fe(t.unwrap()):t instanceof Fe?fe(t._def.innerType):[];class ye extends A{_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.object)return h(t,{code:r.invalid_type,expected:a.object,received:t.parsedType}),f;const s=this.discriminator,n=t.data[s],i=this.optionsMap.get(n);return i?t.common.async?i._parseAsync({data:t.data,path:t.path,parent:t}):i._parseSync({data:t.data,path:t.path,parent:t}):(h(t,{code:r.invalid_union_discriminator,options:Array.from(this.optionsMap.keys()),path:[s]}),f)}get discriminator(){return this._def.discriminator}get options(){return this._def.options}get optionsMap(){return this._def.optionsMap}static create(t,a,s){const r=new Map;for(const e of a){const a=fe(e.shape[t]);if(!a.length)throw new Error(`A discriminator value for key \`${t}\` could not be extracted from all schema options`);for(const s of a){if(r.has(s))throw new Error(`Discriminator property ${String(t)} has duplicate value ${String(s)}`);r.set(s,e)}}return new ye({typeName:e.ZodFirstPartyTypeKind.ZodDiscriminatedUnion,discriminator:t,options:a,optionsMap:r,...N(s)})}}function ve(t,r){const n=s(t),i=s(r);if(t===r)return{valid:!0,data:t};if(n===a.object&&i===a.object){const a=e.util.objectKeys(r),s=e.util.objectKeys(t).filter((e=>-1!==a.indexOf(e))),n={...t,...r};for(const e of s){const a=ve(t[e],r[e]);if(!a.valid)return{valid:!1};n[e]=a.data}return{valid:!0,data:n}}if(n===a.array&&i===a.array){if(t.length!==r.length)return{valid:!1};const e=[];for(let a=0;a{if(_(e)||_(s))return f;const n=ve(e.value,s.value);return n.valid?((g(e)||g(s))&&t.dirty(),{status:t.value,value:n.data}):(h(a,{code:r.invalid_intersection_types}),f)};return a.common.async?Promise.all([this._def.left._parseAsync({data:a.data,path:a.path,parent:a}),this._def.right._parseAsync({data:a.data,path:a.path,parent:a})]).then((([e,t])=>s(e,t))):s(this._def.left._parseSync({data:a.data,path:a.path,parent:a}),this._def.right._parseSync({data:a.data,path:a.path,parent:a}))}}_e.create=(t,a,s)=>new _e({left:t,right:a,typeName:e.ZodFirstPartyTypeKind.ZodIntersection,...N(s)});class ge extends A{_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.array)return h(s,{code:r.invalid_type,expected:a.array,received:s.parsedType}),f;if(s.data.lengththis._def.items.length&&(h(s,{code:r.too_big,maximum:this._def.items.length,inclusive:!0,exact:!1,type:"array"}),t.dirty());const n=[...s.data].map(((e,t)=>{const a=this._def.items[t]||this._def.rest;return a?a._parse(new O(s,e,s.path,t)):null})).filter((e=>!!e));return s.common.async?Promise.all(n).then((e=>m.mergeArray(t,e))):m.mergeArray(t,n)}get items(){return this._def.items}rest(e){return new ge({...this._def,rest:e})}}ge.create=(t,a)=>{if(!Array.isArray(t))throw new Error("You must pass an array of schemas to z.tuple([ ... ])");return new ge({items:t,typeName:e.ZodFirstPartyTypeKind.ZodTuple,rest:null,...N(a)})};class be extends A{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.object)return h(s,{code:r.invalid_type,expected:a.object,received:s.parsedType}),f;const n=[],i=this._def.keyType,o=this._def.valueType;for(const e in s.data)n.push({key:i._parse(new O(s,e,s.path,e)),value:o._parse(new O(s,s.data[e],s.path,e)),alwaysSet:e in s.data});return s.common.async?m.mergeObjectAsync(t,n):m.mergeObjectSync(t,n)}get element(){return this._def.valueType}static create(t,a,s){return new be(a instanceof A?{keyType:t,valueType:a,typeName:e.ZodFirstPartyTypeKind.ZodRecord,...N(s)}:{keyType:X.create(),valueType:t,typeName:e.ZodFirstPartyTypeKind.ZodRecord,...N(a)})}}class ke extends A{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.map)return h(s,{code:r.invalid_type,expected:a.map,received:s.parsedType}),f;const n=this._def.keyType,i=this._def.valueType,o=[...s.data.entries()].map((([e,t],a)=>({key:n._parse(new O(s,e,s.path,[a,"key"])),value:i._parse(new O(s,t,s.path,[a,"value"]))})));if(s.common.async){const e=new Map;return Promise.resolve().then((async()=>{for(const a of o){const s=await a.key,r=await a.value;if("aborted"===s.status||"aborted"===r.status)return f;"dirty"!==s.status&&"dirty"!==r.status||t.dirty(),e.set(s.value,r.value)}return{status:t.value,value:e}}))}{const e=new Map;for(const a of o){const s=a.key,r=a.value;if("aborted"===s.status||"aborted"===r.status)return f;"dirty"!==s.status&&"dirty"!==r.status||t.dirty(),e.set(s.value,r.value)}return{status:t.value,value:e}}}}ke.create=(t,a,s)=>new ke({valueType:a,keyType:t,typeName:e.ZodFirstPartyTypeKind.ZodMap,...N(s)});class xe extends A{_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.set)return h(s,{code:r.invalid_type,expected:a.set,received:s.parsedType}),f;const n=this._def;null!==n.minSize&&s.data.sizen.maxSize.value&&(h(s,{code:r.too_big,maximum:n.maxSize.value,type:"set",inclusive:!0,exact:!1,message:n.maxSize.message}),t.dirty());const i=this._def.valueType;function o(e){const a=new Set;for(const s of e){if("aborted"===s.status)return f;"dirty"===s.status&&t.dirty(),a.add(s.value)}return{status:t.value,value:a}}const d=[...s.data.values()].map(((e,t)=>i._parse(new O(s,e,s.path,t))));return s.common.async?Promise.all(d).then((e=>o(e))):o(d)}min(e,t){return new xe({...this._def,minSize:{value:e,message:w.toString(t)}})}max(e,t){return new xe({...this._def,maxSize:{value:e,message:w.toString(t)}})}size(e,t){return this.min(e,t).max(e,t)}nonempty(e){return this.min(1,e)}}xe.create=(t,a)=>new xe({valueType:t,minSize:null,maxSize:null,typeName:e.ZodFirstPartyTypeKind.ZodSet,...N(a)});class Ze extends A{constructor(){super(...arguments),this.validate=this.implement}_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.function)return h(t,{code:r.invalid_type,expected:a.function,received:t.parsedType}),f;function s(e,a){return l({data:e,path:t.path,errorMaps:[t.common.contextualErrorMap,t.schemaErrorMap,c(),o].filter((e=>!!e)),issueData:{code:r.invalid_arguments,argumentsError:a}})}function n(e,a){return l({data:e,path:t.path,errorMaps:[t.common.contextualErrorMap,t.schemaErrorMap,c(),o].filter((e=>!!e)),issueData:{code:r.invalid_return_type,returnTypeError:a}})}const d={errorMap:t.common.contextualErrorMap},u=t.data;if(this._def.returns instanceof Ne){const e=this;return v((async function(...t){const a=new i([]),r=await e._def.args.parseAsync(t,d).catch((e=>{throw a.addIssue(s(t,e)),a})),o=await Reflect.apply(u,this,r);return await e._def.returns._def.type.parseAsync(o,d).catch((e=>{throw a.addIssue(n(o,e)),a}))}))}{const e=this;return v((function(...t){const a=e._def.args.safeParse(t,d);if(!a.success)throw new i([s(t,a.error)]);const r=Reflect.apply(u,this,a.data),o=e._def.returns.safeParse(r,d);if(!o.success)throw new i([n(r,o.error)]);return o.data}))}}parameters(){return this._def.args}returnType(){return this._def.returns}args(...e){return new Ze({...this._def,args:ge.create(e).rest(de.create())})}returns(e){return new Ze({...this._def,returns:e})}implement(e){return this.parse(e)}strictImplement(e){return this.parse(e)}static create(t,a,s){return new Ze({args:t||ge.create([]).rest(de.create()),returns:a||de.create(),typeName:e.ZodFirstPartyTypeKind.ZodFunction,...N(s)})}}class we extends A{get schema(){return this._def.getter()}_parse(e){const{ctx:t}=this._processInputParams(e);return this._def.getter()._parse({data:t.data,path:t.path,parent:t})}}we.create=(t,a)=>new we({getter:t,typeName:e.ZodFirstPartyTypeKind.ZodLazy,...N(a)});class Te extends A{_parse(e){if(e.data!==this._def.value){const t=this._getOrReturnCtx(e);return h(t,{received:t.data,code:r.invalid_literal,expected:this._def.value}),f}return{status:"valid",value:e.data}}get value(){return this._def.value}}function Pe(t,a){return new Oe({values:t,typeName:e.ZodFirstPartyTypeKind.ZodEnum,...N(a)})}Te.create=(t,a)=>new Te({value:t,typeName:e.ZodFirstPartyTypeKind.ZodLiteral,...N(a)});class Oe extends A{constructor(){super(...arguments),T.set(this,void 0)}_parse(t){if("string"!=typeof t.data){const a=this._getOrReturnCtx(t),s=this._def.values;return h(a,{expected:e.util.joinValues(s),received:a.parsedType,code:r.invalid_type}),f}if(x(this,T,"f")||Z(this,T,new Set(this._def.values),"f"),!x(this,T,"f").has(t.data)){const e=this._getOrReturnCtx(t),a=this._def.values;return h(e,{received:e.data,code:r.invalid_enum_value,options:a}),f}return v(t.data)}get options(){return this._def.values}get enum(){const e={};for(const t of this._def.values)e[t]=t;return e}get Values(){const e={};for(const t of this._def.values)e[t]=t;return e}get Enum(){const e={};for(const t of this._def.values)e[t]=t;return e}extract(e,t=this._def){return Oe.create(e,{...this._def,...t})}exclude(e,t=this._def){return Oe.create(this.options.filter((t=>!e.includes(t))),{...this._def,...t})}}T=new WeakMap,Oe.create=Pe;class Ce extends A{constructor(){super(...arguments),P.set(this,void 0)}_parse(t){const s=e.util.getValidEnumValues(this._def.values),n=this._getOrReturnCtx(t);if(n.parsedType!==a.string&&n.parsedType!==a.number){const t=e.util.objectValues(s);return h(n,{expected:e.util.joinValues(t),received:n.parsedType,code:r.invalid_type}),f}if(x(this,P,"f")||Z(this,P,new Set(e.util.getValidEnumValues(this._def.values)),"f"),!x(this,P,"f").has(t.data)){const t=e.util.objectValues(s);return h(n,{received:n.data,code:r.invalid_enum_value,options:t}),f}return v(t.data)}get enum(){return this._def.values}}P=new WeakMap,Ce.create=(t,a)=>new Ce({values:t,typeName:e.ZodFirstPartyTypeKind.ZodNativeEnum,...N(a)});class Ne extends A{unwrap(){return this._def.type}_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.promise&&!1===t.common.async)return h(t,{code:r.invalid_type,expected:a.promise,received:t.parsedType}),f;const s=t.parsedType===a.promise?t.data:Promise.resolve(t.data);return v(s.then((e=>this._def.type.parseAsync(e,{path:t.path,errorMap:t.common.contextualErrorMap}))))}}Ne.create=(t,a)=>new Ne({type:t,typeName:e.ZodFirstPartyTypeKind.ZodPromise,...N(a)});class Ae extends A{innerType(){return this._def.schema}sourceType(){return this._def.schema._def.typeName===e.ZodFirstPartyTypeKind.ZodEffects?this._def.schema.sourceType():this._def.schema}_parse(t){const{status:a,ctx:s}=this._processInputParams(t),r=this._def.effect||null,n={addIssue:e=>{h(s,e),e.fatal?a.abort():a.dirty()},get path(){return s.path}};if(n.addIssue=n.addIssue.bind(n),"preprocess"===r.type){const e=r.transform(s.data,n);if(s.common.async)return Promise.resolve(e).then((async e=>{if("aborted"===a.value)return f;const t=await this._def.schema._parseAsync({data:e,path:s.path,parent:s});return"aborted"===t.status?f:"dirty"===t.status||"dirty"===a.value?y(t.value):t}));{if("aborted"===a.value)return f;const t=this._def.schema._parseSync({data:e,path:s.path,parent:s});return"aborted"===t.status?f:"dirty"===t.status||"dirty"===a.value?y(t.value):t}}if("refinement"===r.type){const e=e=>{const t=r.refinement(e,n);if(s.common.async)return Promise.resolve(t);if(t instanceof Promise)throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead.");return e};if(!1===s.common.async){const t=this._def.schema._parseSync({data:s.data,path:s.path,parent:s});return"aborted"===t.status?f:("dirty"===t.status&&a.dirty(),e(t.value),{status:a.value,value:t.value})}return this._def.schema._parseAsync({data:s.data,path:s.path,parent:s}).then((t=>"aborted"===t.status?f:("dirty"===t.status&&a.dirty(),e(t.value).then((()=>({status:a.value,value:t.value}))))))}if("transform"===r.type){if(!1===s.common.async){const e=this._def.schema._parseSync({data:s.data,path:s.path,parent:s});if(!b(e))return e;const t=r.transform(e.value,n);if(t instanceof Promise)throw new Error("Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.");return{status:a.value,value:t}}return this._def.schema._parseAsync({data:s.data,path:s.path,parent:s}).then((e=>b(e)?Promise.resolve(r.transform(e.value,n)).then((e=>({status:a.value,value:e}))):e))}e.util.assertNever(r)}}Ae.create=(t,a,s)=>new Ae({schema:t,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:a,...N(s)}),Ae.createWithPreprocess=(t,a,s)=>new Ae({schema:a,effect:{type:"preprocess",transform:t},typeName:e.ZodFirstPartyTypeKind.ZodEffects,...N(s)});class Se extends A{_parse(e){return this._getType(e)===a.undefined?v(void 0):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}}Se.create=(t,a)=>new Se({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodOptional,...N(a)});class je extends A{_parse(e){return this._getType(e)===a.null?v(null):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}}je.create=(t,a)=>new je({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodNullable,...N(a)});class Ee extends A{_parse(e){const{ctx:t}=this._processInputParams(e);let s=t.data;return t.parsedType===a.undefined&&(s=this._def.defaultValue()),this._def.innerType._parse({data:s,path:t.path,parent:t})}removeDefault(){return this._def.innerType}}Ee.create=(t,a)=>new Ee({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodDefault,defaultValue:"function"==typeof a.default?a.default:()=>a.default,...N(a)});class Fe extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a={...t,common:{...t.common,issues:[]}},s=this._def.innerType._parse({data:a.data,path:a.path,parent:{...a}});return k(s)?s.then((e=>({status:"valid",value:"valid"===e.status?e.value:this._def.catchValue({get error(){return new i(a.common.issues)},input:a.data})}))):{status:"valid",value:"valid"===s.status?s.value:this._def.catchValue({get error(){return new i(a.common.issues)},input:a.data})}}removeCatch(){return this._def.innerType}}Fe.create=(t,a)=>new Fe({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodCatch,catchValue:"function"==typeof a.catch?a.catch:()=>a.catch,...N(a)});class Ie extends A{_parse(e){if(this._getType(e)!==a.nan){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.nan,received:t.parsedType}),f}return{status:"valid",value:e.data}}}Ie.create=t=>new Ie({typeName:e.ZodFirstPartyTypeKind.ZodNaN,...N(t)});const Re=Symbol("zod_brand");class Ke extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a=t.data;return this._def.type._parse({data:a,path:t.path,parent:t})}unwrap(){return this._def.type}}class $e extends A{_parse(e){const{status:t,ctx:a}=this._processInputParams(e);if(a.common.async){return(async()=>{const e=await this._def.in._parseAsync({data:a.data,path:a.path,parent:a});return"aborted"===e.status?f:"dirty"===e.status?(t.dirty(),y(e.value)):this._def.out._parseAsync({data:e.value,path:a.path,parent:a})})()}{const e=this._def.in._parseSync({data:a.data,path:a.path,parent:a});return"aborted"===e.status?f:"dirty"===e.status?(t.dirty(),{status:"dirty",value:e.value}):this._def.out._parseSync({data:e.value,path:a.path,parent:a})}}static create(t,a){return new $e({in:t,out:a,typeName:e.ZodFirstPartyTypeKind.ZodPipeline})}}class Me extends A{_parse(e){const t=this._def.innerType._parse(e),a=e=>(b(e)&&(e.value=Object.freeze(e.value)),e);return k(t)?t.then((e=>a(e))):a(t)}unwrap(){return this._def.innerType}}function De(e,t){const a="function"==typeof e?e(t):"string"==typeof e?{message:e}:e;return"string"==typeof a?{message:a}:a}function Le(e,t={},a){return e?oe.create().superRefine(((s,r)=>{var n,i;const o=e(s);if(o instanceof Promise)return o.then((e=>{var n,i;if(!e){const e=De(t,s),o=null===(i=null!==(n=e.fatal)&&void 0!==n?n:a)||void 0===i||i;r.addIssue({code:"custom",...e,fatal:o})}}));if(!o){const e=De(t,s),o=null===(i=null!==(n=e.fatal)&&void 0!==n?n:a)||void 0===i||i;r.addIssue({code:"custom",...e,fatal:o})}})):oe.create()}Me.create=(t,a)=>new Me({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodReadonly,...N(a)});const ze={object:he.lazycreate};var Ve;e.ZodFirstPartyTypeKind=void 0,(Ve=e.ZodFirstPartyTypeKind||(e.ZodFirstPartyTypeKind={})).ZodString="ZodString",Ve.ZodNumber="ZodNumber",Ve.ZodNaN="ZodNaN",Ve.ZodBigInt="ZodBigInt",Ve.ZodBoolean="ZodBoolean",Ve.ZodDate="ZodDate",Ve.ZodSymbol="ZodSymbol",Ve.ZodUndefined="ZodUndefined",Ve.ZodNull="ZodNull",Ve.ZodAny="ZodAny",Ve.ZodUnknown="ZodUnknown",Ve.ZodNever="ZodNever",Ve.ZodVoid="ZodVoid",Ve.ZodArray="ZodArray",Ve.ZodObject="ZodObject",Ve.ZodUnion="ZodUnion",Ve.ZodDiscriminatedUnion="ZodDiscriminatedUnion",Ve.ZodIntersection="ZodIntersection",Ve.ZodTuple="ZodTuple",Ve.ZodRecord="ZodRecord",Ve.ZodMap="ZodMap",Ve.ZodSet="ZodSet",Ve.ZodFunction="ZodFunction",Ve.ZodLazy="ZodLazy",Ve.ZodLiteral="ZodLiteral",Ve.ZodEnum="ZodEnum",Ve.ZodEffects="ZodEffects",Ve.ZodNativeEnum="ZodNativeEnum",Ve.ZodOptional="ZodOptional",Ve.ZodNullable="ZodNullable",Ve.ZodDefault="ZodDefault",Ve.ZodCatch="ZodCatch",Ve.ZodPromise="ZodPromise",Ve.ZodBranded="ZodBranded",Ve.ZodPipeline="ZodPipeline",Ve.ZodReadonly="ZodReadonly";const Ue=(e,t={message:`Input not instance of ${e.name}`})=>Le((t=>t instanceof e),t),Be=X.create,We=ee.create,qe=Ie.create,Je=te.create,Ye=ae.create,He=se.create,Ge=re.create,Xe=ne.create,Qe=ie.create,et=oe.create,tt=de.create,at=ue.create,st=ce.create,rt=le.create,nt=he.create,it=he.strictCreate,ot=me.create,dt=ye.create,ut=_e.create,ct=ge.create,lt=be.create,pt=ke.create,ht=xe.create,mt=Ze.create,ft=we.create,yt=Te.create,vt=Oe.create,_t=Ce.create,gt=Ne.create,bt=Ae.create,kt=Se.create,xt=je.create,Zt=Ae.createWithPreprocess,wt=$e.create,Tt=()=>Be().optional(),Pt=()=>We().optional(),Ot=()=>Ye().optional(),Ct={string:e=>X.create({...e,coerce:!0}),number:e=>ee.create({...e,coerce:!0}),boolean:e=>ae.create({...e,coerce:!0}),bigint:e=>te.create({...e,coerce:!0}),date:e=>se.create({...e,coerce:!0})},Nt=f;var At=Object.freeze({__proto__:null,defaultErrorMap:o,setErrorMap:u,getErrorMap:c,makeIssue:l,EMPTY_PATH:p,addIssueToContext:h,ParseStatus:m,INVALID:f,DIRTY:y,OK:v,isAborted:_,isDirty:g,isValid:b,isAsync:k,get util(){return e.util},get objectUtil(){return e.objectUtil},ZodParsedType:a,getParsedType:s,ZodType:A,datetimeRegex:Y,ZodString:X,ZodNumber:ee,ZodBigInt:te,ZodBoolean:ae,ZodDate:se,ZodSymbol:re,ZodUndefined:ne,ZodNull:ie,ZodAny:oe,ZodUnknown:de,ZodNever:ue,ZodVoid:ce,ZodArray:le,ZodObject:he,ZodUnion:me,ZodDiscriminatedUnion:ye,ZodIntersection:_e,ZodTuple:ge,ZodRecord:be,ZodMap:ke,ZodSet:xe,ZodFunction:Ze,ZodLazy:we,ZodLiteral:Te,ZodEnum:Oe,ZodNativeEnum:Ce,ZodPromise:Ne,ZodEffects:Ae,ZodTransformer:Ae,ZodOptional:Se,ZodNullable:je,ZodDefault:Ee,ZodCatch:Fe,ZodNaN:Ie,BRAND:Re,ZodBranded:Ke,ZodPipeline:$e,ZodReadonly:Me,custom:Le,Schema:A,ZodSchema:A,late:ze,get ZodFirstPartyTypeKind(){return e.ZodFirstPartyTypeKind},coerce:Ct,any:et,array:rt,bigint:Je,boolean:Ye,date:He,discriminatedUnion:dt,effect:bt,enum:vt,function:mt,instanceof:Ue,intersection:ut,lazy:ft,literal:yt,map:pt,nan:qe,nativeEnum:_t,never:at,null:Qe,nullable:xt,number:We,object:nt,oboolean:Ot,onumber:Pt,optional:kt,ostring:Tt,pipeline:wt,preprocess:Zt,promise:gt,record:lt,set:ht,strictObject:it,string:Be,symbol:Ge,transformer:bt,tuple:ct,undefined:Xe,union:ot,unknown:tt,void:st,NEVER:Nt,ZodIssueCode:r,quotelessJson:n,ZodError:i});e.BRAND=Re,e.DIRTY=y,e.EMPTY_PATH=p,e.INVALID=f,e.NEVER=Nt,e.OK=v,e.ParseStatus=m,e.Schema=A,e.ZodAny=oe,e.ZodArray=le,e.ZodBigInt=te,e.ZodBoolean=ae,e.ZodBranded=Ke,e.ZodCatch=Fe,e.ZodDate=se,e.ZodDefault=Ee,e.ZodDiscriminatedUnion=ye,e.ZodEffects=Ae,e.ZodEnum=Oe,e.ZodError=i,e.ZodFunction=Ze,e.ZodIntersection=_e,e.ZodIssueCode=r,e.ZodLazy=we,e.ZodLiteral=Te,e.ZodMap=ke,e.ZodNaN=Ie,e.ZodNativeEnum=Ce,e.ZodNever=ue,e.ZodNull=ie,e.ZodNullable=je,e.ZodNumber=ee,e.ZodObject=he,e.ZodOptional=Se,e.ZodParsedType=a,e.ZodPipeline=$e,e.ZodPromise=Ne,e.ZodReadonly=Me,e.ZodRecord=be,e.ZodSchema=A,e.ZodSet=xe,e.ZodString=X,e.ZodSymbol=re,e.ZodTransformer=Ae,e.ZodTuple=ge,e.ZodType=A,e.ZodUndefined=ne,e.ZodUnion=me,e.ZodUnknown=de,e.ZodVoid=ce,e.addIssueToContext=h,e.any=et,e.array=rt,e.bigint=Je,e.boolean=Ye,e.coerce=Ct,e.custom=Le,e.date=He,e.datetimeRegex=Y,e.default=At,e.defaultErrorMap=o,e.discriminatedUnion=dt,e.effect=bt,e.enum=vt,e.function=mt,e.getErrorMap=c,e.getParsedType=s,e.instanceof=Ue,e.intersection=ut,e.isAborted=_,e.isAsync=k,e.isDirty=g,e.isValid=b,e.late=ze,e.lazy=ft,e.literal=yt,e.makeIssue=l,e.map=pt,e.nan=qe,e.nativeEnum=_t,e.never=at,e.null=Qe,e.nullable=xt,e.number=We,e.object=nt,e.oboolean=Ot,e.onumber=Pt,e.optional=kt,e.ostring=Tt,e.pipeline=wt,e.preprocess=Zt,e.promise=gt,e.quotelessJson=n,e.record=lt,e.set=ht,e.setErrorMap=u,e.strictObject=it,e.string=Be,e.symbol=Ge,e.transformer=bt,e.tuple=ct,e[void 0]=Xe,e.union=ot,e.unknown=tt,e.void=st,e.z=At,Object.defineProperty(e,"__esModule",{value:!0})})); +//# sourceMappingURL=/sm/faeed0596d43c60cf0df488e38219b3725a534b9e2fba99cb50ebc3c0df8e313.map \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 5f50df68..8aea27bf 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1,7 +1,6 @@ import { McpServer } from "./mcp.js"; import { Client } from "../client/index.js"; import { InMemoryTransport } from "../inMemory.js"; -import { z } from "zod"; import { ListToolsResultSchema, CallToolResultSchema, @@ -17,6 +16,9 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +// Note: deliberately using a different Zod version to the one bundled +// with the SDK as that's what our users are likely to be doing +import { z } from "./__tests__/vendor/zod@3.24.4.umd.js"; describe("McpServer", () => { test("should expose underlying Server instance", () => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index dccfa04e..52c90d9d 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -659,16 +659,6 @@ export class McpServer { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } - - // Helper to check if an object is a Zod schema (ZodRawShape) - const isZodRawShape = (obj: unknown): obj is ZodRawShape => { - if (typeof obj !== "object" || obj === null) return false; - - const isEmptyObject = z.object({}).strict().safeParse(obj).success; - - // Check if object is empty or at least one property is a ZodType instance - return isEmptyObject || Object.values(obj as object).some(v => v instanceof ZodType); - }; let description: string | undefined; if (typeof rest[0] === "string") { @@ -931,6 +921,25 @@ const EMPTY_OBJECT_JSON_SCHEMA = { type: "object" as const, }; +// Helper to check if an object is a Zod schema (ZodRawShape) +function isZodRawShape(obj: unknown): obj is ZodRawShape { + if (typeof obj !== "object" || obj === null) return false; + + const isEmptyObject = z.object({}).strict().safeParse(obj).success; + + // Check if object is empty or at least one property is a ZodType instance + return isEmptyObject || Object.values(obj as object).some(isZodType); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isZodType(value: any): value is ZodType { + return value !== null && + typeof value === 'object' && + typeof value.parse === 'function' && + typeof value.safeParse === 'function' && + typeof value._def === 'object'; +} + /** * Additional, optional information for annotating a resource. */ From 3c9496812eddf5511db977a023cbb118d0fb5770 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 9 May 2025 17:43:00 -0400 Subject: [PATCH 016/194] tweak heuristic type test for isZodRawShape - simpler test for empty object - only test public zod properties (parse, safeParse) Confirmed that vendored-library tests still pass. Note that you need the --testPathIgnorePatterns to avoid the __tests__ directory (this is what was breaking the build): $ npm test -- --testPathIgnorePatterns="vendor" --- src/server/mcp.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 52c90d9d..97459fb1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -925,19 +925,18 @@ const EMPTY_OBJECT_JSON_SCHEMA = { function isZodRawShape(obj: unknown): obj is ZodRawShape { if (typeof obj !== "object" || obj === null) return false; - const isEmptyObject = z.object({}).strict().safeParse(obj).success; + const isEmptyObject = Object.keys(obj).length === 0; // Check if object is empty or at least one property is a ZodType instance - return isEmptyObject || Object.values(obj as object).some(isZodType); + // Note: use heuristic check to avoid instanceof failure across different Zod versions + return isEmptyObject || Object.values(obj as object).some(isZodTypeLike); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isZodType(value: any): value is ZodType { +function isZodTypeLike(value: unknown): value is ZodType { return value !== null && typeof value === 'object' && - typeof value.parse === 'function' && - typeof value.safeParse === 'function' && - typeof value._def === 'object'; + 'parse' in value && typeof value.parse === 'function' && + 'safeParse' in value && typeof value.safeParse === 'function'; } /** From 4a295a40434812892ab11badf05530e0bf785cd2 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 9 May 2025 17:48:33 -0400 Subject: [PATCH 017/194] remove vendored library tests --- src/server/__tests__/vendor/zod@3.24.4.umd.d.ts | 5 ----- src/server/__tests__/vendor/zod@3.24.4.umd.js | 8 -------- src/server/mcp.test.ts | 4 +--- 3 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 src/server/__tests__/vendor/zod@3.24.4.umd.d.ts delete mode 100644 src/server/__tests__/vendor/zod@3.24.4.umd.js diff --git a/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts b/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts deleted file mode 100644 index 890bdb5c..00000000 --- a/src/server/__tests__/vendor/zod@3.24.4.umd.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as _zod from 'zod'; - -// This makes the vendored zod use the same types as the actual zod package -export declare const z: typeof _zod.z; -export default _zod.default; \ No newline at end of file diff --git a/src/server/__tests__/vendor/zod@3.24.4.umd.js b/src/server/__tests__/vendor/zod@3.24.4.umd.js deleted file mode 100644 index a4e9e545..00000000 --- a/src/server/__tests__/vendor/zod@3.24.4.umd.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Minified by jsDelivr using Terser v5.39.0. - * Original file: /npm/zod@3.24.4/lib/index.umd.js - * - * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Zod={})}(this,(function(e){"use strict";var t;e.util=void 0,(t=e.util||(e.util={})).assertEqual=e=>e,t.assertIs=function(e){},t.assertNever=function(e){throw new Error},t.arrayToEnum=e=>{const t={};for(const a of e)t[a]=a;return t},t.getValidEnumValues=e=>{const a=t.objectKeys(e).filter((t=>"number"!=typeof e[e[t]])),s={};for(const t of a)s[t]=e[t];return t.objectValues(s)},t.objectValues=e=>t.objectKeys(e).map((function(t){return e[t]})),t.objectKeys="function"==typeof Object.keys?e=>Object.keys(e):e=>{const t=[];for(const a in e)Object.prototype.hasOwnProperty.call(e,a)&&t.push(a);return t},t.find=(e,t)=>{for(const a of e)if(t(a))return a},t.isInteger="function"==typeof Number.isInteger?e=>Number.isInteger(e):e=>"number"==typeof e&&isFinite(e)&&Math.floor(e)===e,t.joinValues=function(e,t=" | "){return e.map((e=>"string"==typeof e?`'${e}'`:e)).join(t)},t.jsonStringifyReplacer=(e,t)=>"bigint"==typeof t?t.toString():t,e.objectUtil=void 0,(e.objectUtil||(e.objectUtil={})).mergeShapes=(e,t)=>({...e,...t});const a=e.util.arrayToEnum(["string","nan","number","integer","float","boolean","date","bigint","symbol","function","undefined","null","array","object","unknown","promise","void","never","map","set"]),s=e=>{switch(typeof e){case"undefined":return a.undefined;case"string":return a.string;case"number":return isNaN(e)?a.nan:a.number;case"boolean":return a.boolean;case"function":return a.function;case"bigint":return a.bigint;case"symbol":return a.symbol;case"object":return Array.isArray(e)?a.array:null===e?a.null:e.then&&"function"==typeof e.then&&e.catch&&"function"==typeof e.catch?a.promise:"undefined"!=typeof Map&&e instanceof Map?a.map:"undefined"!=typeof Set&&e instanceof Set?a.set:"undefined"!=typeof Date&&e instanceof Date?a.date:a.object;default:return a.unknown}},r=e.util.arrayToEnum(["invalid_type","invalid_literal","custom","invalid_union","invalid_union_discriminator","invalid_enum_value","unrecognized_keys","invalid_arguments","invalid_return_type","invalid_date","invalid_string","too_small","too_big","invalid_intersection_types","not_multiple_of","not_finite"]),n=e=>JSON.stringify(e,null,2).replace(/"([^"]+)":/g,"$1:");class i extends Error{get errors(){return this.issues}constructor(e){super(),this.issues=[],this.addIssue=e=>{this.issues=[...this.issues,e]},this.addIssues=(e=[])=>{this.issues=[...this.issues,...e]};const t=new.target.prototype;Object.setPrototypeOf?Object.setPrototypeOf(this,t):this.__proto__=t,this.name="ZodError",this.issues=e}format(e){const t=e||function(e){return e.message},a={_errors:[]},s=e=>{for(const r of e.issues)if("invalid_union"===r.code)r.unionErrors.map(s);else if("invalid_return_type"===r.code)s(r.returnTypeError);else if("invalid_arguments"===r.code)s(r.argumentsError);else if(0===r.path.length)a._errors.push(t(r));else{let e=a,s=0;for(;se.message){const t={},a=[];for(const s of this.issues)s.path.length>0?(t[s.path[0]]=t[s.path[0]]||[],t[s.path[0]].push(e(s))):a.push(e(s));return{formErrors:a,fieldErrors:t}}get formErrors(){return this.flatten()}}i.create=e=>new i(e);const o=(t,s)=>{let n;switch(t.code){case r.invalid_type:n=t.received===a.undefined?"Required":`Expected ${t.expected}, received ${t.received}`;break;case r.invalid_literal:n=`Invalid literal value, expected ${JSON.stringify(t.expected,e.util.jsonStringifyReplacer)}`;break;case r.unrecognized_keys:n=`Unrecognized key(s) in object: ${e.util.joinValues(t.keys,", ")}`;break;case r.invalid_union:n="Invalid input";break;case r.invalid_union_discriminator:n=`Invalid discriminator value. Expected ${e.util.joinValues(t.options)}`;break;case r.invalid_enum_value:n=`Invalid enum value. Expected ${e.util.joinValues(t.options)}, received '${t.received}'`;break;case r.invalid_arguments:n="Invalid function arguments";break;case r.invalid_return_type:n="Invalid function return type";break;case r.invalid_date:n="Invalid date";break;case r.invalid_string:"object"==typeof t.validation?"includes"in t.validation?(n=`Invalid input: must include "${t.validation.includes}"`,"number"==typeof t.validation.position&&(n=`${n} at one or more positions greater than or equal to ${t.validation.position}`)):"startsWith"in t.validation?n=`Invalid input: must start with "${t.validation.startsWith}"`:"endsWith"in t.validation?n=`Invalid input: must end with "${t.validation.endsWith}"`:e.util.assertNever(t.validation):n="regex"!==t.validation?`Invalid ${t.validation}`:"Invalid";break;case r.too_small:n="array"===t.type?`Array must contain ${t.exact?"exactly":t.inclusive?"at least":"more than"} ${t.minimum} element(s)`:"string"===t.type?`String must contain ${t.exact?"exactly":t.inclusive?"at least":"over"} ${t.minimum} character(s)`:"number"===t.type?`Number must be ${t.exact?"exactly equal to ":t.inclusive?"greater than or equal to ":"greater than "}${t.minimum}`:"date"===t.type?`Date must be ${t.exact?"exactly equal to ":t.inclusive?"greater than or equal to ":"greater than "}${new Date(Number(t.minimum))}`:"Invalid input";break;case r.too_big:n="array"===t.type?`Array must contain ${t.exact?"exactly":t.inclusive?"at most":"less than"} ${t.maximum} element(s)`:"string"===t.type?`String must contain ${t.exact?"exactly":t.inclusive?"at most":"under"} ${t.maximum} character(s)`:"number"===t.type?`Number must be ${t.exact?"exactly":t.inclusive?"less than or equal to":"less than"} ${t.maximum}`:"bigint"===t.type?`BigInt must be ${t.exact?"exactly":t.inclusive?"less than or equal to":"less than"} ${t.maximum}`:"date"===t.type?`Date must be ${t.exact?"exactly":t.inclusive?"smaller than or equal to":"smaller than"} ${new Date(Number(t.maximum))}`:"Invalid input";break;case r.custom:n="Invalid input";break;case r.invalid_intersection_types:n="Intersection results could not be merged";break;case r.not_multiple_of:n=`Number must be a multiple of ${t.multipleOf}`;break;case r.not_finite:n="Number must be finite";break;default:n=s.defaultError,e.util.assertNever(t)}return{message:n}};let d=o;function u(e){d=e}function c(){return d}const l=e=>{const{data:t,path:a,errorMaps:s,issueData:r}=e,n=[...a,...r.path||[]],i={...r,path:n};if(void 0!==r.message)return{...r,path:n,message:r.message};let o="";const d=s.filter((e=>!!e)).slice().reverse();for(const e of d)o=e(i,{data:t,defaultError:o}).message;return{...r,path:n,message:o}},p=[];function h(e,t){const a=c(),s=l({issueData:t,data:e.data,path:e.path,errorMaps:[e.common.contextualErrorMap,e.schemaErrorMap,a,a===o?void 0:o].filter((e=>!!e))});e.common.issues.push(s)}class m{constructor(){this.value="valid"}dirty(){"valid"===this.value&&(this.value="dirty")}abort(){"aborted"!==this.value&&(this.value="aborted")}static mergeArray(e,t){const a=[];for(const s of t){if("aborted"===s.status)return f;"dirty"===s.status&&e.dirty(),a.push(s.value)}return{status:e.value,value:a}}static async mergeObjectAsync(e,t){const a=[];for(const e of t){const t=await e.key,s=await e.value;a.push({key:t,value:s})}return m.mergeObjectSync(e,a)}static mergeObjectSync(e,t){const a={};for(const s of t){const{key:t,value:r}=s;if("aborted"===t.status)return f;if("aborted"===r.status)return f;"dirty"===t.status&&e.dirty(),"dirty"===r.status&&e.dirty(),"__proto__"===t.value||void 0===r.value&&!s.alwaysSet||(a[t.value]=r.value)}return{status:e.value,value:a}}}const f=Object.freeze({status:"aborted"}),y=e=>({status:"dirty",value:e}),v=e=>({status:"valid",value:e}),_=e=>"aborted"===e.status,g=e=>"dirty"===e.status,b=e=>"valid"===e.status,k=e=>"undefined"!=typeof Promise&&e instanceof Promise;function x(e,t,a,s){if("a"===a&&!s)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof t?e!==t||!s:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===a?s:"a"===a?s.call(e):s?s.value:t.get(e)}function Z(e,t,a,s,r){if("m"===s)throw new TypeError("Private method is not writable");if("a"===s&&!r)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof t?e!==t||!r:!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return"a"===s?r.call(e,a):r?r.value=a:t.set(e,a),a}var w,T,P;"function"==typeof SuppressedError&&SuppressedError,function(e){e.errToObj=e=>"string"==typeof e?{message:e}:e||{},e.toString=e=>"string"==typeof e?e:null==e?void 0:e.message}(w||(w={}));class O{constructor(e,t,a,s){this._cachedPath=[],this.parent=e,this.data=t,this._path=a,this._key=s}get path(){return this._cachedPath.length||(this._key instanceof Array?this._cachedPath.push(...this._path,...this._key):this._cachedPath.push(...this._path,this._key)),this._cachedPath}}const C=(e,t)=>{if(b(t))return{success:!0,data:t.value};if(!e.common.issues.length)throw new Error("Validation failed but no issues detected.");return{success:!1,get error(){if(this._error)return this._error;const t=new i(e.common.issues);return this._error=t,this._error}}};function N(e){if(!e)return{};const{errorMap:t,invalid_type_error:a,required_error:s,description:r}=e;if(t&&(a||s))throw new Error('Can\'t use "invalid_type_error" or "required_error" in conjunction with custom error map.');if(t)return{errorMap:t,description:r};return{errorMap:(t,r)=>{var n,i;const{message:o}=e;return"invalid_enum_value"===t.code?{message:null!=o?o:r.defaultError}:void 0===r.data?{message:null!==(n=null!=o?o:s)&&void 0!==n?n:r.defaultError}:"invalid_type"!==t.code?{message:r.defaultError}:{message:null!==(i=null!=o?o:a)&&void 0!==i?i:r.defaultError}},description:r}}class A{get description(){return this._def.description}_getType(e){return s(e.data)}_getOrReturnCtx(e,t){return t||{common:e.parent.common,data:e.data,parsedType:s(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}_processInputParams(e){return{status:new m,ctx:{common:e.parent.common,data:e.data,parsedType:s(e.data),schemaErrorMap:this._def.errorMap,path:e.path,parent:e.parent}}}_parseSync(e){const t=this._parse(e);if(k(t))throw new Error("Synchronous parse encountered promise.");return t}_parseAsync(e){const t=this._parse(e);return Promise.resolve(t)}parse(e,t){const a=this.safeParse(e,t);if(a.success)return a.data;throw a.error}safeParse(e,t){var a;const r={common:{issues:[],async:null!==(a=null==t?void 0:t.async)&&void 0!==a&&a,contextualErrorMap:null==t?void 0:t.errorMap},path:(null==t?void 0:t.path)||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)},n=this._parseSync({data:e,path:r.path,parent:r});return C(r,n)}"~validate"(e){var t,a;const r={common:{issues:[],async:!!this["~standard"].async},path:[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)};if(!this["~standard"].async)try{const t=this._parseSync({data:e,path:[],parent:r});return b(t)?{value:t.value}:{issues:r.common.issues}}catch(e){(null===(a=null===(t=null==e?void 0:e.message)||void 0===t?void 0:t.toLowerCase())||void 0===a?void 0:a.includes("encountered"))&&(this["~standard"].async=!0),r.common={issues:[],async:!0}}return this._parseAsync({data:e,path:[],parent:r}).then((e=>b(e)?{value:e.value}:{issues:r.common.issues}))}async parseAsync(e,t){const a=await this.safeParseAsync(e,t);if(a.success)return a.data;throw a.error}async safeParseAsync(e,t){const a={common:{issues:[],contextualErrorMap:null==t?void 0:t.errorMap,async:!0},path:(null==t?void 0:t.path)||[],schemaErrorMap:this._def.errorMap,parent:null,data:e,parsedType:s(e)},r=this._parse({data:e,path:a.path,parent:a}),n=await(k(r)?r:Promise.resolve(r));return C(a,n)}refine(e,t){const a=e=>"string"==typeof t||void 0===t?{message:t}:"function"==typeof t?t(e):t;return this._refinement(((t,s)=>{const n=e(t),i=()=>s.addIssue({code:r.custom,...a(t)});return"undefined"!=typeof Promise&&n instanceof Promise?n.then((e=>!!e||(i(),!1))):!!n||(i(),!1)}))}refinement(e,t){return this._refinement(((a,s)=>!!e(a)||(s.addIssue("function"==typeof t?t(a,s):t),!1)))}_refinement(t){return new Ae({schema:this,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:{type:"refinement",refinement:t}})}superRefine(e){return this._refinement(e)}constructor(e){this.spa=this.safeParseAsync,this._def=e,this.parse=this.parse.bind(this),this.safeParse=this.safeParse.bind(this),this.parseAsync=this.parseAsync.bind(this),this.safeParseAsync=this.safeParseAsync.bind(this),this.spa=this.spa.bind(this),this.refine=this.refine.bind(this),this.refinement=this.refinement.bind(this),this.superRefine=this.superRefine.bind(this),this.optional=this.optional.bind(this),this.nullable=this.nullable.bind(this),this.nullish=this.nullish.bind(this),this.array=this.array.bind(this),this.promise=this.promise.bind(this),this.or=this.or.bind(this),this.and=this.and.bind(this),this.transform=this.transform.bind(this),this.brand=this.brand.bind(this),this.default=this.default.bind(this),this.catch=this.catch.bind(this),this.describe=this.describe.bind(this),this.pipe=this.pipe.bind(this),this.readonly=this.readonly.bind(this),this.isNullable=this.isNullable.bind(this),this.isOptional=this.isOptional.bind(this),this["~standard"]={version:1,vendor:"zod",validate:e=>this["~validate"](e)}}optional(){return Se.create(this,this._def)}nullable(){return je.create(this,this._def)}nullish(){return this.nullable().optional()}array(){return le.create(this)}promise(){return Ne.create(this,this._def)}or(e){return me.create([this,e],this._def)}and(e){return _e.create(this,e,this._def)}transform(t){return new Ae({...N(this._def),schema:this,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:{type:"transform",transform:t}})}default(t){const a="function"==typeof t?t:()=>t;return new Ee({...N(this._def),innerType:this,defaultValue:a,typeName:e.ZodFirstPartyTypeKind.ZodDefault})}brand(){return new Ke({typeName:e.ZodFirstPartyTypeKind.ZodBranded,type:this,...N(this._def)})}catch(t){const a="function"==typeof t?t:()=>t;return new Fe({...N(this._def),innerType:this,catchValue:a,typeName:e.ZodFirstPartyTypeKind.ZodCatch})}describe(e){return new(0,this.constructor)({...this._def,description:e})}pipe(e){return $e.create(this,e)}readonly(){return Me.create(this)}isOptional(){return this.safeParse(void 0).success}isNullable(){return this.safeParse(null).success}}const S=/^c[^\s-]{8,}$/i,j=/^[0-9a-z]+$/,E=/^[0-9A-HJKMNP-TV-Z]{26}$/i,F=/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i,I=/^[a-z0-9_-]{21}$/i,R=/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/,K=/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/,$=/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;let M;const D=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,L=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,z=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/,V=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,U=/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,B=/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,W="((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))",q=new RegExp(`^${W}$`);function J(e){let t="[0-5]\\d";e.precision?t=`${t}\\.\\d{${e.precision}}`:null==e.precision&&(t=`${t}(\\.\\d+)?`);return`([01]\\d|2[0-3]):[0-5]\\d(:${t})${e.precision?"+":"?"}`}function Y(e){let t=`${W}T${J(e)}`;const a=[];return a.push(e.local?"Z?":"Z"),e.offset&&a.push("([+-]\\d{2}:?\\d{2})"),t=`${t}(${a.join("|")})`,new RegExp(`^${t}$`)}function H(e,t){if(!R.test(e))return!1;try{const[a]=e.split("."),s=a.replace(/-/g,"+").replace(/_/g,"/").padEnd(a.length+(4-a.length%4)%4,"="),r=JSON.parse(atob(s));return"object"==typeof r&&null!==r&&(!(!r.typ||!r.alg)&&(!t||r.alg===t))}catch(e){return!1}}function G(e,t){return!("v4"!==t&&t||!L.test(e))||!("v6"!==t&&t||!V.test(e))}class X extends A{_parse(t){this._def.coerce&&(t.data=String(t.data));if(this._getType(t)!==a.string){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.string,received:e.parsedType}),f}const s=new m;let n;for(const a of this._def.checks)if("min"===a.kind)t.data.lengtha.value&&(n=this._getOrReturnCtx(t,n),h(n,{code:r.too_big,maximum:a.value,type:"string",inclusive:!0,exact:!1,message:a.message}),s.dirty());else if("length"===a.kind){const e=t.data.length>a.value,i=t.data.lengthe.test(t)),{validation:t,code:r.invalid_string,...w.errToObj(a)})}_addCheck(e){return new X({...this._def,checks:[...this._def.checks,e]})}email(e){return this._addCheck({kind:"email",...w.errToObj(e)})}url(e){return this._addCheck({kind:"url",...w.errToObj(e)})}emoji(e){return this._addCheck({kind:"emoji",...w.errToObj(e)})}uuid(e){return this._addCheck({kind:"uuid",...w.errToObj(e)})}nanoid(e){return this._addCheck({kind:"nanoid",...w.errToObj(e)})}cuid(e){return this._addCheck({kind:"cuid",...w.errToObj(e)})}cuid2(e){return this._addCheck({kind:"cuid2",...w.errToObj(e)})}ulid(e){return this._addCheck({kind:"ulid",...w.errToObj(e)})}base64(e){return this._addCheck({kind:"base64",...w.errToObj(e)})}base64url(e){return this._addCheck({kind:"base64url",...w.errToObj(e)})}jwt(e){return this._addCheck({kind:"jwt",...w.errToObj(e)})}ip(e){return this._addCheck({kind:"ip",...w.errToObj(e)})}cidr(e){return this._addCheck({kind:"cidr",...w.errToObj(e)})}datetime(e){var t,a;return"string"==typeof e?this._addCheck({kind:"datetime",precision:null,offset:!1,local:!1,message:e}):this._addCheck({kind:"datetime",precision:void 0===(null==e?void 0:e.precision)?null:null==e?void 0:e.precision,offset:null!==(t=null==e?void 0:e.offset)&&void 0!==t&&t,local:null!==(a=null==e?void 0:e.local)&&void 0!==a&&a,...w.errToObj(null==e?void 0:e.message)})}date(e){return this._addCheck({kind:"date",message:e})}time(e){return"string"==typeof e?this._addCheck({kind:"time",precision:null,message:e}):this._addCheck({kind:"time",precision:void 0===(null==e?void 0:e.precision)?null:null==e?void 0:e.precision,...w.errToObj(null==e?void 0:e.message)})}duration(e){return this._addCheck({kind:"duration",...w.errToObj(e)})}regex(e,t){return this._addCheck({kind:"regex",regex:e,...w.errToObj(t)})}includes(e,t){return this._addCheck({kind:"includes",value:e,position:null==t?void 0:t.position,...w.errToObj(null==t?void 0:t.message)})}startsWith(e,t){return this._addCheck({kind:"startsWith",value:e,...w.errToObj(t)})}endsWith(e,t){return this._addCheck({kind:"endsWith",value:e,...w.errToObj(t)})}min(e,t){return this._addCheck({kind:"min",value:e,...w.errToObj(t)})}max(e,t){return this._addCheck({kind:"max",value:e,...w.errToObj(t)})}length(e,t){return this._addCheck({kind:"length",value:e,...w.errToObj(t)})}nonempty(e){return this.min(1,w.errToObj(e))}trim(){return new X({...this._def,checks:[...this._def.checks,{kind:"trim"}]})}toLowerCase(){return new X({...this._def,checks:[...this._def.checks,{kind:"toLowerCase"}]})}toUpperCase(){return new X({...this._def,checks:[...this._def.checks,{kind:"toUpperCase"}]})}get isDatetime(){return!!this._def.checks.find((e=>"datetime"===e.kind))}get isDate(){return!!this._def.checks.find((e=>"date"===e.kind))}get isTime(){return!!this._def.checks.find((e=>"time"===e.kind))}get isDuration(){return!!this._def.checks.find((e=>"duration"===e.kind))}get isEmail(){return!!this._def.checks.find((e=>"email"===e.kind))}get isURL(){return!!this._def.checks.find((e=>"url"===e.kind))}get isEmoji(){return!!this._def.checks.find((e=>"emoji"===e.kind))}get isUUID(){return!!this._def.checks.find((e=>"uuid"===e.kind))}get isNANOID(){return!!this._def.checks.find((e=>"nanoid"===e.kind))}get isCUID(){return!!this._def.checks.find((e=>"cuid"===e.kind))}get isCUID2(){return!!this._def.checks.find((e=>"cuid2"===e.kind))}get isULID(){return!!this._def.checks.find((e=>"ulid"===e.kind))}get isIP(){return!!this._def.checks.find((e=>"ip"===e.kind))}get isCIDR(){return!!this._def.checks.find((e=>"cidr"===e.kind))}get isBase64(){return!!this._def.checks.find((e=>"base64"===e.kind))}get isBase64url(){return!!this._def.checks.find((e=>"base64url"===e.kind))}get minLength(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxLength(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.values?a:s;return parseInt(e.toFixed(r).replace(".",""))%parseInt(t.toFixed(r).replace(".",""))/Math.pow(10,r)}X.create=t=>{var a;return new X({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodString,coerce:null!==(a=null==t?void 0:t.coerce)&&void 0!==a&&a,...N(t)})};class ee extends A{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte,this.step=this.multipleOf}_parse(t){this._def.coerce&&(t.data=Number(t.data));if(this._getType(t)!==a.number){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.number,received:e.parsedType}),f}let s;const n=new m;for(const a of this._def.checks)if("int"===a.kind)e.util.isInteger(t.data)||(s=this._getOrReturnCtx(t,s),h(s,{code:r.invalid_type,expected:"integer",received:"float",message:a.message}),n.dirty());else if("min"===a.kind){(a.inclusive?t.dataa.value:t.data>=a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.too_big,maximum:a.value,type:"number",inclusive:a.inclusive,exact:!1,message:a.message}),n.dirty())}else"multipleOf"===a.kind?0!==Q(t.data,a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):"finite"===a.kind?Number.isFinite(t.data)||(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_finite,message:a.message}),n.dirty()):e.util.assertNever(a);return{status:n.value,value:t.data}}gte(e,t){return this.setLimit("min",e,!0,w.toString(t))}gt(e,t){return this.setLimit("min",e,!1,w.toString(t))}lte(e,t){return this.setLimit("max",e,!0,w.toString(t))}lt(e,t){return this.setLimit("max",e,!1,w.toString(t))}setLimit(e,t,a,s){return new ee({...this._def,checks:[...this._def.checks,{kind:e,value:t,inclusive:a,message:w.toString(s)}]})}_addCheck(e){return new ee({...this._def,checks:[...this._def.checks,e]})}int(e){return this._addCheck({kind:"int",message:w.toString(e)})}positive(e){return this._addCheck({kind:"min",value:0,inclusive:!1,message:w.toString(e)})}negative(e){return this._addCheck({kind:"max",value:0,inclusive:!1,message:w.toString(e)})}nonpositive(e){return this._addCheck({kind:"max",value:0,inclusive:!0,message:w.toString(e)})}nonnegative(e){return this._addCheck({kind:"min",value:0,inclusive:!0,message:w.toString(e)})}multipleOf(e,t){return this._addCheck({kind:"multipleOf",value:e,message:w.toString(t)})}finite(e){return this._addCheck({kind:"finite",message:w.toString(e)})}safe(e){return this._addCheck({kind:"min",inclusive:!0,value:Number.MIN_SAFE_INTEGER,message:w.toString(e)})._addCheck({kind:"max",inclusive:!0,value:Number.MAX_SAFE_INTEGER,message:w.toString(e)})}get minValue(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxValue(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.value"int"===t.kind||"multipleOf"===t.kind&&e.util.isInteger(t.value)))}get isFinite(){let e=null,t=null;for(const a of this._def.checks){if("finite"===a.kind||"int"===a.kind||"multipleOf"===a.kind)return!0;"min"===a.kind?(null===t||a.value>t)&&(t=a.value):"max"===a.kind&&(null===e||a.valuenew ee({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodNumber,coerce:(null==t?void 0:t.coerce)||!1,...N(t)});class te extends A{constructor(){super(...arguments),this.min=this.gte,this.max=this.lte}_parse(t){if(this._def.coerce)try{t.data=BigInt(t.data)}catch(e){return this._getInvalidInput(t)}if(this._getType(t)!==a.bigint)return this._getInvalidInput(t);let s;const n=new m;for(const a of this._def.checks)if("min"===a.kind){(a.inclusive?t.dataa.value:t.data>=a.value)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.too_big,type:"bigint",maximum:a.value,inclusive:a.inclusive,message:a.message}),n.dirty())}else"multipleOf"===a.kind?t.data%a.value!==BigInt(0)&&(s=this._getOrReturnCtx(t,s),h(s,{code:r.not_multiple_of,multipleOf:a.value,message:a.message}),n.dirty()):e.util.assertNever(a);return{status:n.value,value:t.data}}_getInvalidInput(e){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.bigint,received:t.parsedType}),f}gte(e,t){return this.setLimit("min",e,!0,w.toString(t))}gt(e,t){return this.setLimit("min",e,!1,w.toString(t))}lte(e,t){return this.setLimit("max",e,!0,w.toString(t))}lt(e,t){return this.setLimit("max",e,!1,w.toString(t))}setLimit(e,t,a,s){return new te({...this._def,checks:[...this._def.checks,{kind:e,value:t,inclusive:a,message:w.toString(s)}]})}_addCheck(e){return new te({...this._def,checks:[...this._def.checks,e]})}positive(e){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!1,message:w.toString(e)})}negative(e){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!1,message:w.toString(e)})}nonpositive(e){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!0,message:w.toString(e)})}nonnegative(e){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!0,message:w.toString(e)})}multipleOf(e,t){return this._addCheck({kind:"multipleOf",value:e,message:w.toString(t)})}get minValue(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return e}get maxValue(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.value{var a;return new te({checks:[],typeName:e.ZodFirstPartyTypeKind.ZodBigInt,coerce:null!==(a=null==t?void 0:t.coerce)&&void 0!==a&&a,...N(t)})};class ae extends A{_parse(e){this._def.coerce&&(e.data=Boolean(e.data));if(this._getType(e)!==a.boolean){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.boolean,received:t.parsedType}),f}return v(e.data)}}ae.create=t=>new ae({typeName:e.ZodFirstPartyTypeKind.ZodBoolean,coerce:(null==t?void 0:t.coerce)||!1,...N(t)});class se extends A{_parse(t){this._def.coerce&&(t.data=new Date(t.data));if(this._getType(t)!==a.date){const e=this._getOrReturnCtx(t);return h(e,{code:r.invalid_type,expected:a.date,received:e.parsedType}),f}if(isNaN(t.data.getTime())){return h(this._getOrReturnCtx(t),{code:r.invalid_date}),f}const s=new m;let n;for(const a of this._def.checks)"min"===a.kind?t.data.getTime()a.value&&(n=this._getOrReturnCtx(t,n),h(n,{code:r.too_big,message:a.message,inclusive:!0,exact:!1,maximum:a.value,type:"date"}),s.dirty()):e.util.assertNever(a);return{status:s.value,value:new Date(t.data.getTime())}}_addCheck(e){return new se({...this._def,checks:[...this._def.checks,e]})}min(e,t){return this._addCheck({kind:"min",value:e.getTime(),message:w.toString(t)})}max(e,t){return this._addCheck({kind:"max",value:e.getTime(),message:w.toString(t)})}get minDate(){let e=null;for(const t of this._def.checks)"min"===t.kind&&(null===e||t.value>e)&&(e=t.value);return null!=e?new Date(e):null}get maxDate(){let e=null;for(const t of this._def.checks)"max"===t.kind&&(null===e||t.valuenew se({checks:[],coerce:(null==t?void 0:t.coerce)||!1,typeName:e.ZodFirstPartyTypeKind.ZodDate,...N(t)});class re extends A{_parse(e){if(this._getType(e)!==a.symbol){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.symbol,received:t.parsedType}),f}return v(e.data)}}re.create=t=>new re({typeName:e.ZodFirstPartyTypeKind.ZodSymbol,...N(t)});class ne extends A{_parse(e){if(this._getType(e)!==a.undefined){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.undefined,received:t.parsedType}),f}return v(e.data)}}ne.create=t=>new ne({typeName:e.ZodFirstPartyTypeKind.ZodUndefined,...N(t)});class ie extends A{_parse(e){if(this._getType(e)!==a.null){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.null,received:t.parsedType}),f}return v(e.data)}}ie.create=t=>new ie({typeName:e.ZodFirstPartyTypeKind.ZodNull,...N(t)});class oe extends A{constructor(){super(...arguments),this._any=!0}_parse(e){return v(e.data)}}oe.create=t=>new oe({typeName:e.ZodFirstPartyTypeKind.ZodAny,...N(t)});class de extends A{constructor(){super(...arguments),this._unknown=!0}_parse(e){return v(e.data)}}de.create=t=>new de({typeName:e.ZodFirstPartyTypeKind.ZodUnknown,...N(t)});class ue extends A{_parse(e){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.never,received:t.parsedType}),f}}ue.create=t=>new ue({typeName:e.ZodFirstPartyTypeKind.ZodNever,...N(t)});class ce extends A{_parse(e){if(this._getType(e)!==a.undefined){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.void,received:t.parsedType}),f}return v(e.data)}}ce.create=t=>new ce({typeName:e.ZodFirstPartyTypeKind.ZodVoid,...N(t)});class le extends A{_parse(e){const{ctx:t,status:s}=this._processInputParams(e),n=this._def;if(t.parsedType!==a.array)return h(t,{code:r.invalid_type,expected:a.array,received:t.parsedType}),f;if(null!==n.exactLength){const e=t.data.length>n.exactLength.value,a=t.data.lengthn.maxLength.value&&(h(t,{code:r.too_big,maximum:n.maxLength.value,type:"array",inclusive:!0,exact:!1,message:n.maxLength.message}),s.dirty()),t.common.async)return Promise.all([...t.data].map(((e,a)=>n.type._parseAsync(new O(t,e,t.path,a))))).then((e=>m.mergeArray(s,e)));const i=[...t.data].map(((e,a)=>n.type._parseSync(new O(t,e,t.path,a))));return m.mergeArray(s,i)}get element(){return this._def.type}min(e,t){return new le({...this._def,minLength:{value:e,message:w.toString(t)}})}max(e,t){return new le({...this._def,maxLength:{value:e,message:w.toString(t)}})}length(e,t){return new le({...this._def,exactLength:{value:e,message:w.toString(t)}})}nonempty(e){return this.min(1,e)}}function pe(e){if(e instanceof he){const t={};for(const a in e.shape){const s=e.shape[a];t[a]=Se.create(pe(s))}return new he({...e._def,shape:()=>t})}return e instanceof le?new le({...e._def,type:pe(e.element)}):e instanceof Se?Se.create(pe(e.unwrap())):e instanceof je?je.create(pe(e.unwrap())):e instanceof ge?ge.create(e.items.map((e=>pe(e)))):e}le.create=(t,a)=>new le({type:t,minLength:null,maxLength:null,exactLength:null,typeName:e.ZodFirstPartyTypeKind.ZodArray,...N(a)});class he extends A{constructor(){super(...arguments),this._cached=null,this.nonstrict=this.passthrough,this.augment=this.extend}_getCached(){if(null!==this._cached)return this._cached;const t=this._def.shape(),a=e.util.objectKeys(t);return this._cached={shape:t,keys:a}}_parse(e){if(this._getType(e)!==a.object){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.object,received:t.parsedType}),f}const{status:t,ctx:s}=this._processInputParams(e),{shape:n,keys:i}=this._getCached(),o=[];if(!(this._def.catchall instanceof ue&&"strip"===this._def.unknownKeys))for(const e in s.data)i.includes(e)||o.push(e);const d=[];for(const e of i){const t=n[e],a=s.data[e];d.push({key:{status:"valid",value:e},value:t._parse(new O(s,a,s.path,e)),alwaysSet:e in s.data})}if(this._def.catchall instanceof ue){const e=this._def.unknownKeys;if("passthrough"===e)for(const e of o)d.push({key:{status:"valid",value:e},value:{status:"valid",value:s.data[e]}});else if("strict"===e)o.length>0&&(h(s,{code:r.unrecognized_keys,keys:o}),t.dirty());else if("strip"!==e)throw new Error("Internal ZodObject error: invalid unknownKeys value.")}else{const e=this._def.catchall;for(const t of o){const a=s.data[t];d.push({key:{status:"valid",value:t},value:e._parse(new O(s,a,s.path,t)),alwaysSet:t in s.data})}}return s.common.async?Promise.resolve().then((async()=>{const e=[];for(const t of d){const a=await t.key,s=await t.value;e.push({key:a,value:s,alwaysSet:t.alwaysSet})}return e})).then((e=>m.mergeObjectSync(t,e))):m.mergeObjectSync(t,d)}get shape(){return this._def.shape()}strict(e){return w.errToObj,new he({...this._def,unknownKeys:"strict",...void 0!==e?{errorMap:(t,a)=>{var s,r,n,i;const o=null!==(n=null===(r=(s=this._def).errorMap)||void 0===r?void 0:r.call(s,t,a).message)&&void 0!==n?n:a.defaultError;return"unrecognized_keys"===t.code?{message:null!==(i=w.errToObj(e).message)&&void 0!==i?i:o}:{message:o}}}:{}})}strip(){return new he({...this._def,unknownKeys:"strip"})}passthrough(){return new he({...this._def,unknownKeys:"passthrough"})}extend(e){return new he({...this._def,shape:()=>({...this._def.shape(),...e})})}merge(t){return new he({unknownKeys:t._def.unknownKeys,catchall:t._def.catchall,shape:()=>({...this._def.shape(),...t._def.shape()}),typeName:e.ZodFirstPartyTypeKind.ZodObject})}setKey(e,t){return this.augment({[e]:t})}catchall(e){return new he({...this._def,catchall:e})}pick(t){const a={};return e.util.objectKeys(t).forEach((e=>{t[e]&&this.shape[e]&&(a[e]=this.shape[e])})),new he({...this._def,shape:()=>a})}omit(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{t[e]||(a[e]=this.shape[e])})),new he({...this._def,shape:()=>a})}deepPartial(){return pe(this)}partial(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{const s=this.shape[e];t&&!t[e]?a[e]=s:a[e]=s.optional()})),new he({...this._def,shape:()=>a})}required(t){const a={};return e.util.objectKeys(this.shape).forEach((e=>{if(t&&!t[e])a[e]=this.shape[e];else{let t=this.shape[e];for(;t instanceof Se;)t=t._def.innerType;a[e]=t}})),new he({...this._def,shape:()=>a})}keyof(){return Pe(e.util.objectKeys(this.shape))}}he.create=(t,a)=>new he({shape:()=>t,unknownKeys:"strip",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)}),he.strictCreate=(t,a)=>new he({shape:()=>t,unknownKeys:"strict",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)}),he.lazycreate=(t,a)=>new he({shape:t,unknownKeys:"strip",catchall:ue.create(),typeName:e.ZodFirstPartyTypeKind.ZodObject,...N(a)});class me extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a=this._def.options;if(t.common.async)return Promise.all(a.map((async e=>{const a={...t,common:{...t.common,issues:[]},parent:null};return{result:await e._parseAsync({data:t.data,path:t.path,parent:a}),ctx:a}}))).then((function(e){for(const t of e)if("valid"===t.result.status)return t.result;for(const a of e)if("dirty"===a.result.status)return t.common.issues.push(...a.ctx.common.issues),a.result;const a=e.map((e=>new i(e.ctx.common.issues)));return h(t,{code:r.invalid_union,unionErrors:a}),f}));{let e;const s=[];for(const r of a){const a={...t,common:{...t.common,issues:[]},parent:null},n=r._parseSync({data:t.data,path:t.path,parent:a});if("valid"===n.status)return n;"dirty"!==n.status||e||(e={result:n,ctx:a}),a.common.issues.length&&s.push(a.common.issues)}if(e)return t.common.issues.push(...e.ctx.common.issues),e.result;const n=s.map((e=>new i(e)));return h(t,{code:r.invalid_union,unionErrors:n}),f}}get options(){return this._def.options}}me.create=(t,a)=>new me({options:t,typeName:e.ZodFirstPartyTypeKind.ZodUnion,...N(a)});const fe=t=>t instanceof we?fe(t.schema):t instanceof Ae?fe(t.innerType()):t instanceof Te?[t.value]:t instanceof Oe?t.options:t instanceof Ce?e.util.objectValues(t.enum):t instanceof Ee?fe(t._def.innerType):t instanceof ne?[void 0]:t instanceof ie?[null]:t instanceof Se?[void 0,...fe(t.unwrap())]:t instanceof je?[null,...fe(t.unwrap())]:t instanceof Ke||t instanceof Me?fe(t.unwrap()):t instanceof Fe?fe(t._def.innerType):[];class ye extends A{_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.object)return h(t,{code:r.invalid_type,expected:a.object,received:t.parsedType}),f;const s=this.discriminator,n=t.data[s],i=this.optionsMap.get(n);return i?t.common.async?i._parseAsync({data:t.data,path:t.path,parent:t}):i._parseSync({data:t.data,path:t.path,parent:t}):(h(t,{code:r.invalid_union_discriminator,options:Array.from(this.optionsMap.keys()),path:[s]}),f)}get discriminator(){return this._def.discriminator}get options(){return this._def.options}get optionsMap(){return this._def.optionsMap}static create(t,a,s){const r=new Map;for(const e of a){const a=fe(e.shape[t]);if(!a.length)throw new Error(`A discriminator value for key \`${t}\` could not be extracted from all schema options`);for(const s of a){if(r.has(s))throw new Error(`Discriminator property ${String(t)} has duplicate value ${String(s)}`);r.set(s,e)}}return new ye({typeName:e.ZodFirstPartyTypeKind.ZodDiscriminatedUnion,discriminator:t,options:a,optionsMap:r,...N(s)})}}function ve(t,r){const n=s(t),i=s(r);if(t===r)return{valid:!0,data:t};if(n===a.object&&i===a.object){const a=e.util.objectKeys(r),s=e.util.objectKeys(t).filter((e=>-1!==a.indexOf(e))),n={...t,...r};for(const e of s){const a=ve(t[e],r[e]);if(!a.valid)return{valid:!1};n[e]=a.data}return{valid:!0,data:n}}if(n===a.array&&i===a.array){if(t.length!==r.length)return{valid:!1};const e=[];for(let a=0;a{if(_(e)||_(s))return f;const n=ve(e.value,s.value);return n.valid?((g(e)||g(s))&&t.dirty(),{status:t.value,value:n.data}):(h(a,{code:r.invalid_intersection_types}),f)};return a.common.async?Promise.all([this._def.left._parseAsync({data:a.data,path:a.path,parent:a}),this._def.right._parseAsync({data:a.data,path:a.path,parent:a})]).then((([e,t])=>s(e,t))):s(this._def.left._parseSync({data:a.data,path:a.path,parent:a}),this._def.right._parseSync({data:a.data,path:a.path,parent:a}))}}_e.create=(t,a,s)=>new _e({left:t,right:a,typeName:e.ZodFirstPartyTypeKind.ZodIntersection,...N(s)});class ge extends A{_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.array)return h(s,{code:r.invalid_type,expected:a.array,received:s.parsedType}),f;if(s.data.lengththis._def.items.length&&(h(s,{code:r.too_big,maximum:this._def.items.length,inclusive:!0,exact:!1,type:"array"}),t.dirty());const n=[...s.data].map(((e,t)=>{const a=this._def.items[t]||this._def.rest;return a?a._parse(new O(s,e,s.path,t)):null})).filter((e=>!!e));return s.common.async?Promise.all(n).then((e=>m.mergeArray(t,e))):m.mergeArray(t,n)}get items(){return this._def.items}rest(e){return new ge({...this._def,rest:e})}}ge.create=(t,a)=>{if(!Array.isArray(t))throw new Error("You must pass an array of schemas to z.tuple([ ... ])");return new ge({items:t,typeName:e.ZodFirstPartyTypeKind.ZodTuple,rest:null,...N(a)})};class be extends A{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.object)return h(s,{code:r.invalid_type,expected:a.object,received:s.parsedType}),f;const n=[],i=this._def.keyType,o=this._def.valueType;for(const e in s.data)n.push({key:i._parse(new O(s,e,s.path,e)),value:o._parse(new O(s,s.data[e],s.path,e)),alwaysSet:e in s.data});return s.common.async?m.mergeObjectAsync(t,n):m.mergeObjectSync(t,n)}get element(){return this._def.valueType}static create(t,a,s){return new be(a instanceof A?{keyType:t,valueType:a,typeName:e.ZodFirstPartyTypeKind.ZodRecord,...N(s)}:{keyType:X.create(),valueType:t,typeName:e.ZodFirstPartyTypeKind.ZodRecord,...N(a)})}}class ke extends A{get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.map)return h(s,{code:r.invalid_type,expected:a.map,received:s.parsedType}),f;const n=this._def.keyType,i=this._def.valueType,o=[...s.data.entries()].map((([e,t],a)=>({key:n._parse(new O(s,e,s.path,[a,"key"])),value:i._parse(new O(s,t,s.path,[a,"value"]))})));if(s.common.async){const e=new Map;return Promise.resolve().then((async()=>{for(const a of o){const s=await a.key,r=await a.value;if("aborted"===s.status||"aborted"===r.status)return f;"dirty"!==s.status&&"dirty"!==r.status||t.dirty(),e.set(s.value,r.value)}return{status:t.value,value:e}}))}{const e=new Map;for(const a of o){const s=a.key,r=a.value;if("aborted"===s.status||"aborted"===r.status)return f;"dirty"!==s.status&&"dirty"!==r.status||t.dirty(),e.set(s.value,r.value)}return{status:t.value,value:e}}}}ke.create=(t,a,s)=>new ke({valueType:a,keyType:t,typeName:e.ZodFirstPartyTypeKind.ZodMap,...N(s)});class xe extends A{_parse(e){const{status:t,ctx:s}=this._processInputParams(e);if(s.parsedType!==a.set)return h(s,{code:r.invalid_type,expected:a.set,received:s.parsedType}),f;const n=this._def;null!==n.minSize&&s.data.sizen.maxSize.value&&(h(s,{code:r.too_big,maximum:n.maxSize.value,type:"set",inclusive:!0,exact:!1,message:n.maxSize.message}),t.dirty());const i=this._def.valueType;function o(e){const a=new Set;for(const s of e){if("aborted"===s.status)return f;"dirty"===s.status&&t.dirty(),a.add(s.value)}return{status:t.value,value:a}}const d=[...s.data.values()].map(((e,t)=>i._parse(new O(s,e,s.path,t))));return s.common.async?Promise.all(d).then((e=>o(e))):o(d)}min(e,t){return new xe({...this._def,minSize:{value:e,message:w.toString(t)}})}max(e,t){return new xe({...this._def,maxSize:{value:e,message:w.toString(t)}})}size(e,t){return this.min(e,t).max(e,t)}nonempty(e){return this.min(1,e)}}xe.create=(t,a)=>new xe({valueType:t,minSize:null,maxSize:null,typeName:e.ZodFirstPartyTypeKind.ZodSet,...N(a)});class Ze extends A{constructor(){super(...arguments),this.validate=this.implement}_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.function)return h(t,{code:r.invalid_type,expected:a.function,received:t.parsedType}),f;function s(e,a){return l({data:e,path:t.path,errorMaps:[t.common.contextualErrorMap,t.schemaErrorMap,c(),o].filter((e=>!!e)),issueData:{code:r.invalid_arguments,argumentsError:a}})}function n(e,a){return l({data:e,path:t.path,errorMaps:[t.common.contextualErrorMap,t.schemaErrorMap,c(),o].filter((e=>!!e)),issueData:{code:r.invalid_return_type,returnTypeError:a}})}const d={errorMap:t.common.contextualErrorMap},u=t.data;if(this._def.returns instanceof Ne){const e=this;return v((async function(...t){const a=new i([]),r=await e._def.args.parseAsync(t,d).catch((e=>{throw a.addIssue(s(t,e)),a})),o=await Reflect.apply(u,this,r);return await e._def.returns._def.type.parseAsync(o,d).catch((e=>{throw a.addIssue(n(o,e)),a}))}))}{const e=this;return v((function(...t){const a=e._def.args.safeParse(t,d);if(!a.success)throw new i([s(t,a.error)]);const r=Reflect.apply(u,this,a.data),o=e._def.returns.safeParse(r,d);if(!o.success)throw new i([n(r,o.error)]);return o.data}))}}parameters(){return this._def.args}returnType(){return this._def.returns}args(...e){return new Ze({...this._def,args:ge.create(e).rest(de.create())})}returns(e){return new Ze({...this._def,returns:e})}implement(e){return this.parse(e)}strictImplement(e){return this.parse(e)}static create(t,a,s){return new Ze({args:t||ge.create([]).rest(de.create()),returns:a||de.create(),typeName:e.ZodFirstPartyTypeKind.ZodFunction,...N(s)})}}class we extends A{get schema(){return this._def.getter()}_parse(e){const{ctx:t}=this._processInputParams(e);return this._def.getter()._parse({data:t.data,path:t.path,parent:t})}}we.create=(t,a)=>new we({getter:t,typeName:e.ZodFirstPartyTypeKind.ZodLazy,...N(a)});class Te extends A{_parse(e){if(e.data!==this._def.value){const t=this._getOrReturnCtx(e);return h(t,{received:t.data,code:r.invalid_literal,expected:this._def.value}),f}return{status:"valid",value:e.data}}get value(){return this._def.value}}function Pe(t,a){return new Oe({values:t,typeName:e.ZodFirstPartyTypeKind.ZodEnum,...N(a)})}Te.create=(t,a)=>new Te({value:t,typeName:e.ZodFirstPartyTypeKind.ZodLiteral,...N(a)});class Oe extends A{constructor(){super(...arguments),T.set(this,void 0)}_parse(t){if("string"!=typeof t.data){const a=this._getOrReturnCtx(t),s=this._def.values;return h(a,{expected:e.util.joinValues(s),received:a.parsedType,code:r.invalid_type}),f}if(x(this,T,"f")||Z(this,T,new Set(this._def.values),"f"),!x(this,T,"f").has(t.data)){const e=this._getOrReturnCtx(t),a=this._def.values;return h(e,{received:e.data,code:r.invalid_enum_value,options:a}),f}return v(t.data)}get options(){return this._def.values}get enum(){const e={};for(const t of this._def.values)e[t]=t;return e}get Values(){const e={};for(const t of this._def.values)e[t]=t;return e}get Enum(){const e={};for(const t of this._def.values)e[t]=t;return e}extract(e,t=this._def){return Oe.create(e,{...this._def,...t})}exclude(e,t=this._def){return Oe.create(this.options.filter((t=>!e.includes(t))),{...this._def,...t})}}T=new WeakMap,Oe.create=Pe;class Ce extends A{constructor(){super(...arguments),P.set(this,void 0)}_parse(t){const s=e.util.getValidEnumValues(this._def.values),n=this._getOrReturnCtx(t);if(n.parsedType!==a.string&&n.parsedType!==a.number){const t=e.util.objectValues(s);return h(n,{expected:e.util.joinValues(t),received:n.parsedType,code:r.invalid_type}),f}if(x(this,P,"f")||Z(this,P,new Set(e.util.getValidEnumValues(this._def.values)),"f"),!x(this,P,"f").has(t.data)){const t=e.util.objectValues(s);return h(n,{received:n.data,code:r.invalid_enum_value,options:t}),f}return v(t.data)}get enum(){return this._def.values}}P=new WeakMap,Ce.create=(t,a)=>new Ce({values:t,typeName:e.ZodFirstPartyTypeKind.ZodNativeEnum,...N(a)});class Ne extends A{unwrap(){return this._def.type}_parse(e){const{ctx:t}=this._processInputParams(e);if(t.parsedType!==a.promise&&!1===t.common.async)return h(t,{code:r.invalid_type,expected:a.promise,received:t.parsedType}),f;const s=t.parsedType===a.promise?t.data:Promise.resolve(t.data);return v(s.then((e=>this._def.type.parseAsync(e,{path:t.path,errorMap:t.common.contextualErrorMap}))))}}Ne.create=(t,a)=>new Ne({type:t,typeName:e.ZodFirstPartyTypeKind.ZodPromise,...N(a)});class Ae extends A{innerType(){return this._def.schema}sourceType(){return this._def.schema._def.typeName===e.ZodFirstPartyTypeKind.ZodEffects?this._def.schema.sourceType():this._def.schema}_parse(t){const{status:a,ctx:s}=this._processInputParams(t),r=this._def.effect||null,n={addIssue:e=>{h(s,e),e.fatal?a.abort():a.dirty()},get path(){return s.path}};if(n.addIssue=n.addIssue.bind(n),"preprocess"===r.type){const e=r.transform(s.data,n);if(s.common.async)return Promise.resolve(e).then((async e=>{if("aborted"===a.value)return f;const t=await this._def.schema._parseAsync({data:e,path:s.path,parent:s});return"aborted"===t.status?f:"dirty"===t.status||"dirty"===a.value?y(t.value):t}));{if("aborted"===a.value)return f;const t=this._def.schema._parseSync({data:e,path:s.path,parent:s});return"aborted"===t.status?f:"dirty"===t.status||"dirty"===a.value?y(t.value):t}}if("refinement"===r.type){const e=e=>{const t=r.refinement(e,n);if(s.common.async)return Promise.resolve(t);if(t instanceof Promise)throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead.");return e};if(!1===s.common.async){const t=this._def.schema._parseSync({data:s.data,path:s.path,parent:s});return"aborted"===t.status?f:("dirty"===t.status&&a.dirty(),e(t.value),{status:a.value,value:t.value})}return this._def.schema._parseAsync({data:s.data,path:s.path,parent:s}).then((t=>"aborted"===t.status?f:("dirty"===t.status&&a.dirty(),e(t.value).then((()=>({status:a.value,value:t.value}))))))}if("transform"===r.type){if(!1===s.common.async){const e=this._def.schema._parseSync({data:s.data,path:s.path,parent:s});if(!b(e))return e;const t=r.transform(e.value,n);if(t instanceof Promise)throw new Error("Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.");return{status:a.value,value:t}}return this._def.schema._parseAsync({data:s.data,path:s.path,parent:s}).then((e=>b(e)?Promise.resolve(r.transform(e.value,n)).then((e=>({status:a.value,value:e}))):e))}e.util.assertNever(r)}}Ae.create=(t,a,s)=>new Ae({schema:t,typeName:e.ZodFirstPartyTypeKind.ZodEffects,effect:a,...N(s)}),Ae.createWithPreprocess=(t,a,s)=>new Ae({schema:a,effect:{type:"preprocess",transform:t},typeName:e.ZodFirstPartyTypeKind.ZodEffects,...N(s)});class Se extends A{_parse(e){return this._getType(e)===a.undefined?v(void 0):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}}Se.create=(t,a)=>new Se({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodOptional,...N(a)});class je extends A{_parse(e){return this._getType(e)===a.null?v(null):this._def.innerType._parse(e)}unwrap(){return this._def.innerType}}je.create=(t,a)=>new je({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodNullable,...N(a)});class Ee extends A{_parse(e){const{ctx:t}=this._processInputParams(e);let s=t.data;return t.parsedType===a.undefined&&(s=this._def.defaultValue()),this._def.innerType._parse({data:s,path:t.path,parent:t})}removeDefault(){return this._def.innerType}}Ee.create=(t,a)=>new Ee({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodDefault,defaultValue:"function"==typeof a.default?a.default:()=>a.default,...N(a)});class Fe extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a={...t,common:{...t.common,issues:[]}},s=this._def.innerType._parse({data:a.data,path:a.path,parent:{...a}});return k(s)?s.then((e=>({status:"valid",value:"valid"===e.status?e.value:this._def.catchValue({get error(){return new i(a.common.issues)},input:a.data})}))):{status:"valid",value:"valid"===s.status?s.value:this._def.catchValue({get error(){return new i(a.common.issues)},input:a.data})}}removeCatch(){return this._def.innerType}}Fe.create=(t,a)=>new Fe({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodCatch,catchValue:"function"==typeof a.catch?a.catch:()=>a.catch,...N(a)});class Ie extends A{_parse(e){if(this._getType(e)!==a.nan){const t=this._getOrReturnCtx(e);return h(t,{code:r.invalid_type,expected:a.nan,received:t.parsedType}),f}return{status:"valid",value:e.data}}}Ie.create=t=>new Ie({typeName:e.ZodFirstPartyTypeKind.ZodNaN,...N(t)});const Re=Symbol("zod_brand");class Ke extends A{_parse(e){const{ctx:t}=this._processInputParams(e),a=t.data;return this._def.type._parse({data:a,path:t.path,parent:t})}unwrap(){return this._def.type}}class $e extends A{_parse(e){const{status:t,ctx:a}=this._processInputParams(e);if(a.common.async){return(async()=>{const e=await this._def.in._parseAsync({data:a.data,path:a.path,parent:a});return"aborted"===e.status?f:"dirty"===e.status?(t.dirty(),y(e.value)):this._def.out._parseAsync({data:e.value,path:a.path,parent:a})})()}{const e=this._def.in._parseSync({data:a.data,path:a.path,parent:a});return"aborted"===e.status?f:"dirty"===e.status?(t.dirty(),{status:"dirty",value:e.value}):this._def.out._parseSync({data:e.value,path:a.path,parent:a})}}static create(t,a){return new $e({in:t,out:a,typeName:e.ZodFirstPartyTypeKind.ZodPipeline})}}class Me extends A{_parse(e){const t=this._def.innerType._parse(e),a=e=>(b(e)&&(e.value=Object.freeze(e.value)),e);return k(t)?t.then((e=>a(e))):a(t)}unwrap(){return this._def.innerType}}function De(e,t){const a="function"==typeof e?e(t):"string"==typeof e?{message:e}:e;return"string"==typeof a?{message:a}:a}function Le(e,t={},a){return e?oe.create().superRefine(((s,r)=>{var n,i;const o=e(s);if(o instanceof Promise)return o.then((e=>{var n,i;if(!e){const e=De(t,s),o=null===(i=null!==(n=e.fatal)&&void 0!==n?n:a)||void 0===i||i;r.addIssue({code:"custom",...e,fatal:o})}}));if(!o){const e=De(t,s),o=null===(i=null!==(n=e.fatal)&&void 0!==n?n:a)||void 0===i||i;r.addIssue({code:"custom",...e,fatal:o})}})):oe.create()}Me.create=(t,a)=>new Me({innerType:t,typeName:e.ZodFirstPartyTypeKind.ZodReadonly,...N(a)});const ze={object:he.lazycreate};var Ve;e.ZodFirstPartyTypeKind=void 0,(Ve=e.ZodFirstPartyTypeKind||(e.ZodFirstPartyTypeKind={})).ZodString="ZodString",Ve.ZodNumber="ZodNumber",Ve.ZodNaN="ZodNaN",Ve.ZodBigInt="ZodBigInt",Ve.ZodBoolean="ZodBoolean",Ve.ZodDate="ZodDate",Ve.ZodSymbol="ZodSymbol",Ve.ZodUndefined="ZodUndefined",Ve.ZodNull="ZodNull",Ve.ZodAny="ZodAny",Ve.ZodUnknown="ZodUnknown",Ve.ZodNever="ZodNever",Ve.ZodVoid="ZodVoid",Ve.ZodArray="ZodArray",Ve.ZodObject="ZodObject",Ve.ZodUnion="ZodUnion",Ve.ZodDiscriminatedUnion="ZodDiscriminatedUnion",Ve.ZodIntersection="ZodIntersection",Ve.ZodTuple="ZodTuple",Ve.ZodRecord="ZodRecord",Ve.ZodMap="ZodMap",Ve.ZodSet="ZodSet",Ve.ZodFunction="ZodFunction",Ve.ZodLazy="ZodLazy",Ve.ZodLiteral="ZodLiteral",Ve.ZodEnum="ZodEnum",Ve.ZodEffects="ZodEffects",Ve.ZodNativeEnum="ZodNativeEnum",Ve.ZodOptional="ZodOptional",Ve.ZodNullable="ZodNullable",Ve.ZodDefault="ZodDefault",Ve.ZodCatch="ZodCatch",Ve.ZodPromise="ZodPromise",Ve.ZodBranded="ZodBranded",Ve.ZodPipeline="ZodPipeline",Ve.ZodReadonly="ZodReadonly";const Ue=(e,t={message:`Input not instance of ${e.name}`})=>Le((t=>t instanceof e),t),Be=X.create,We=ee.create,qe=Ie.create,Je=te.create,Ye=ae.create,He=se.create,Ge=re.create,Xe=ne.create,Qe=ie.create,et=oe.create,tt=de.create,at=ue.create,st=ce.create,rt=le.create,nt=he.create,it=he.strictCreate,ot=me.create,dt=ye.create,ut=_e.create,ct=ge.create,lt=be.create,pt=ke.create,ht=xe.create,mt=Ze.create,ft=we.create,yt=Te.create,vt=Oe.create,_t=Ce.create,gt=Ne.create,bt=Ae.create,kt=Se.create,xt=je.create,Zt=Ae.createWithPreprocess,wt=$e.create,Tt=()=>Be().optional(),Pt=()=>We().optional(),Ot=()=>Ye().optional(),Ct={string:e=>X.create({...e,coerce:!0}),number:e=>ee.create({...e,coerce:!0}),boolean:e=>ae.create({...e,coerce:!0}),bigint:e=>te.create({...e,coerce:!0}),date:e=>se.create({...e,coerce:!0})},Nt=f;var At=Object.freeze({__proto__:null,defaultErrorMap:o,setErrorMap:u,getErrorMap:c,makeIssue:l,EMPTY_PATH:p,addIssueToContext:h,ParseStatus:m,INVALID:f,DIRTY:y,OK:v,isAborted:_,isDirty:g,isValid:b,isAsync:k,get util(){return e.util},get objectUtil(){return e.objectUtil},ZodParsedType:a,getParsedType:s,ZodType:A,datetimeRegex:Y,ZodString:X,ZodNumber:ee,ZodBigInt:te,ZodBoolean:ae,ZodDate:se,ZodSymbol:re,ZodUndefined:ne,ZodNull:ie,ZodAny:oe,ZodUnknown:de,ZodNever:ue,ZodVoid:ce,ZodArray:le,ZodObject:he,ZodUnion:me,ZodDiscriminatedUnion:ye,ZodIntersection:_e,ZodTuple:ge,ZodRecord:be,ZodMap:ke,ZodSet:xe,ZodFunction:Ze,ZodLazy:we,ZodLiteral:Te,ZodEnum:Oe,ZodNativeEnum:Ce,ZodPromise:Ne,ZodEffects:Ae,ZodTransformer:Ae,ZodOptional:Se,ZodNullable:je,ZodDefault:Ee,ZodCatch:Fe,ZodNaN:Ie,BRAND:Re,ZodBranded:Ke,ZodPipeline:$e,ZodReadonly:Me,custom:Le,Schema:A,ZodSchema:A,late:ze,get ZodFirstPartyTypeKind(){return e.ZodFirstPartyTypeKind},coerce:Ct,any:et,array:rt,bigint:Je,boolean:Ye,date:He,discriminatedUnion:dt,effect:bt,enum:vt,function:mt,instanceof:Ue,intersection:ut,lazy:ft,literal:yt,map:pt,nan:qe,nativeEnum:_t,never:at,null:Qe,nullable:xt,number:We,object:nt,oboolean:Ot,onumber:Pt,optional:kt,ostring:Tt,pipeline:wt,preprocess:Zt,promise:gt,record:lt,set:ht,strictObject:it,string:Be,symbol:Ge,transformer:bt,tuple:ct,undefined:Xe,union:ot,unknown:tt,void:st,NEVER:Nt,ZodIssueCode:r,quotelessJson:n,ZodError:i});e.BRAND=Re,e.DIRTY=y,e.EMPTY_PATH=p,e.INVALID=f,e.NEVER=Nt,e.OK=v,e.ParseStatus=m,e.Schema=A,e.ZodAny=oe,e.ZodArray=le,e.ZodBigInt=te,e.ZodBoolean=ae,e.ZodBranded=Ke,e.ZodCatch=Fe,e.ZodDate=se,e.ZodDefault=Ee,e.ZodDiscriminatedUnion=ye,e.ZodEffects=Ae,e.ZodEnum=Oe,e.ZodError=i,e.ZodFunction=Ze,e.ZodIntersection=_e,e.ZodIssueCode=r,e.ZodLazy=we,e.ZodLiteral=Te,e.ZodMap=ke,e.ZodNaN=Ie,e.ZodNativeEnum=Ce,e.ZodNever=ue,e.ZodNull=ie,e.ZodNullable=je,e.ZodNumber=ee,e.ZodObject=he,e.ZodOptional=Se,e.ZodParsedType=a,e.ZodPipeline=$e,e.ZodPromise=Ne,e.ZodReadonly=Me,e.ZodRecord=be,e.ZodSchema=A,e.ZodSet=xe,e.ZodString=X,e.ZodSymbol=re,e.ZodTransformer=Ae,e.ZodTuple=ge,e.ZodType=A,e.ZodUndefined=ne,e.ZodUnion=me,e.ZodUnknown=de,e.ZodVoid=ce,e.addIssueToContext=h,e.any=et,e.array=rt,e.bigint=Je,e.boolean=Ye,e.coerce=Ct,e.custom=Le,e.date=He,e.datetimeRegex=Y,e.default=At,e.defaultErrorMap=o,e.discriminatedUnion=dt,e.effect=bt,e.enum=vt,e.function=mt,e.getErrorMap=c,e.getParsedType=s,e.instanceof=Ue,e.intersection=ut,e.isAborted=_,e.isAsync=k,e.isDirty=g,e.isValid=b,e.late=ze,e.lazy=ft,e.literal=yt,e.makeIssue=l,e.map=pt,e.nan=qe,e.nativeEnum=_t,e.never=at,e.null=Qe,e.nullable=xt,e.number=We,e.object=nt,e.oboolean=Ot,e.onumber=Pt,e.optional=kt,e.ostring=Tt,e.pipeline=wt,e.preprocess=Zt,e.promise=gt,e.quotelessJson=n,e.record=lt,e.set=ht,e.setErrorMap=u,e.strictObject=it,e.string=Be,e.symbol=Ge,e.transformer=bt,e.tuple=ct,e[void 0]=Xe,e.union=ot,e.unknown=tt,e.void=st,e.z=At,Object.defineProperty(e,"__esModule",{value:!0})})); -//# sourceMappingURL=/sm/faeed0596d43c60cf0df488e38219b3725a534b9e2fba99cb50ebc3c0df8e313.map \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 8aea27bf..5f50df68 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1,6 +1,7 @@ import { McpServer } from "./mcp.js"; import { Client } from "../client/index.js"; import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; import { ListToolsResultSchema, CallToolResultSchema, @@ -16,9 +17,6 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; -// Note: deliberately using a different Zod version to the one bundled -// with the SDK as that's what our users are likely to be doing -import { z } from "./__tests__/vendor/zod@3.24.4.umd.js"; describe("McpServer", () => { test("should expose underlying Server instance", () => { From 9fe501b9bf8e7cf5a39a019af6e21314b389c066 Mon Sep 17 00:00:00 2001 From: Boyquotes Date: Mon, 12 May 2025 07:34:37 +0200 Subject: [PATCH 018/194] Fix notificaions in README.md notificaions -> notifications --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9102260..d0f8a534 100644 --- a/README.md +++ b/README.md @@ -512,7 +512,7 @@ server.tool( ### Dynamic Servers -If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notificaions: +If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; From e5909502c2ba1db0c782fd83aa3b50112a2401cb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 12 May 2025 10:09:49 +0100 Subject: [PATCH 019/194] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fa6f9545..f078e8f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.1", + "version": "1.11.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 5a25fe3a6ca5f6a159c249a63fe2f358cda8a9c5 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 8 May 2025 13:31:18 -0400 Subject: [PATCH 020/194] fix: preserve custom paths in SSE endpoint URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an SSE client connects with a custom path (e.g., /api/v1/custom/sse), ensure the endpoint URL maintains the same base path structure but with /messages instead of /sse. This fixes issues where custom endpoints were getting collapsed to the root path. Should fix issues reported in modelcontextprotocol/inspector#313 and #296 Tests demonstrating the issue were added in PR #439 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/sse.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5e9f0cf0..1e481773 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -143,7 +143,18 @@ export class SSEClientTransport implements Transport { const messageEvent = event as MessageEvent; try { + // Use the original URL as the base to resolve the received endpoint this._endpoint = new URL(messageEvent.data, this._url); + + // If the original URL had a custom path, preserve it in the endpoint URL + const originalPath = this._url.pathname; + if (originalPath && originalPath !== '/' && originalPath !== '/sse') { + // Extract the base path from the original URL (everything before the /sse suffix) + const basePath = originalPath.replace(/\/sse$/, ''); + // The endpoint should use the same base path but with /messages instead of /sse + this._endpoint.pathname = basePath + '/messages'; + } + if (this._endpoint.origin !== this._url.origin) { throw new Error( `Endpoint origin does not match connection origin: ${this._endpoint.origin}`, From 2f522bf3707222ed75b0105c369ce8989f437cac Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 2 May 2025 06:36:33 -0700 Subject: [PATCH 021/194] Add failing tests --- src/client/sse.test.ts | 157 +++++++++++++++++++++++++++++------------ 1 file changed, 111 insertions(+), 46 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 77b28508..fc7a86c4 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -68,6 +68,71 @@ describe("SSEClientTransport", () => { }); describe("connection handling", () => { + it("maintains custom path when constructing endpoint URL", async () => { + // Create a URL with a custom path + const customPathUrl = new URL("/custom/path/sse", baseUrl); + transport = new SSEClientTransport(customPathUrl); + + // Start the transport + await transport.start(); + + // Send a test message to verify the endpoint URL + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + + // Verify the POST request maintains the custom path + expect(lastServerRequest.url).toBe("/custom/path/messages"); + }); + + it("handles multiple levels of custom paths", async () => { + // Test with a deeper nested path + const nestedPathUrl = new URL("/api/v1/custom/deep/path/sse", baseUrl); + transport = new SSEClientTransport(nestedPathUrl); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + + // Verify the POST request maintains the full custom path + expect(lastServerRequest.url).toBe("/api/v1/custom/deep/path/messages"); + }); + + it("maintains custom path for SSE connection", async () => { + const customPathUrl = new URL("/custom/path/sse", baseUrl); + transport = new SSEClientTransport(customPathUrl); + await transport.start(); + expect(lastServerRequest.url).toBe("/custom/path/sse"); + }); + + it("handles URLs with query parameters", async () => { + const urlWithQuery = new URL("/custom/path/sse?param=value", baseUrl); + transport = new SSEClientTransport(urlWithQuery); + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + expect(lastServerRequest.url).toBe("/custom/path/messages"); + }); + it("establishes SSE connection and receives endpoint", async () => { transport = new SSEClientTransport(baseUrl); await transport.start(); @@ -397,18 +462,18 @@ describe("SSEClientTransport", () => { return; } - 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: ${baseUrl.href}\n\n`); + 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: ${baseUrl.href}\n\n`); break; case "POST": - res.writeHead(401); - res.end(); + res.writeHead(401); + res.end(); break; } }); @@ -517,25 +582,25 @@ describe("SSEClientTransport", () => { return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { + res.writeHead(401).end(); + return; + } + + if (auth === "Bearer new-token") { + 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: ${baseUrl.href}\n\n`); + connectionAttempts++; + return; + } + res.writeHead(401).end(); - return; - } - - if (auth === "Bearer new-token") { - 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: ${baseUrl.href}\n\n`); - connectionAttempts++; - return; - } - - res.writeHead(401).end(); }); await new Promise(resolve => { @@ -610,13 +675,13 @@ describe("SSEClientTransport", () => { return; } - 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: ${baseUrl.href}\n\n`); + 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: ${baseUrl.href}\n\n`); break; case "POST": { @@ -625,19 +690,19 @@ describe("SSEClientTransport", () => { return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { - res.writeHead(401).end(); - return; - } - - if (auth === "Bearer new-token") { - res.writeHead(200).end(); - postAttempts++; - return; - } - + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { res.writeHead(401).end(); + return; + } + + if (auth === "Bearer new-token") { + res.writeHead(200).end(); + postAttempts++; + return; + } + + res.writeHead(401).end(); break; } } From f2fc8738599d173e44d9a92debd65321d462796d Mon Sep 17 00:00:00 2001 From: Rafael Moura Date: Tue, 13 May 2025 15:37:26 -0300 Subject: [PATCH 022/194] chore: fix cross-spawn version to 7.0.5 (issue: 488) --- package-lock.json | 6 +++--- package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1165b751..ed986a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.11.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.11.3", "license": "MIT", "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", diff --git a/package.json b/package.json index f078e8f6..233fd9e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.2", + "version": "1.11.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -48,7 +48,7 @@ "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", 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 023/194] Bump formidable in the npm_and_yarn group across 1 directory Bumps the npm_and_yarn group with 1 update in the / directory: [formidable](https://github.com/node-formidable/formidable). Updates `formidable` from 3.5.2 to 3.5.4 - [Release notes](https://github.com/node-formidable/formidable/releases) - [Changelog](https://github.com/node-formidable/formidable/blob/master/CHANGELOG.md) - [Commits](https://github.com/node-formidable/formidable/commits) --- updated-dependencies: - dependency-name: formidable dependency-version: 3.5.4 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed986a69..fe738fcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1557,6 +1557,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1592,6 +1605,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3689,16 +3712,19 @@ } }, "node_modules/formidable": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", - "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "hexoid": "^2.0.0", "once": "^1.4.0" }, + "engines": { + "node": ">=14.0.0" + }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -3953,16 +3979,6 @@ "node": ">= 0.4" } }, - "node_modules/hexoid": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", - "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", From 66dec56467b8f562b2c8cec8c2b41f97dd819777 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 15 May 2025 01:45:18 +0800 Subject: [PATCH 024/194] feat(auth): support custom scope in authorization requests (#491) * feat(auth): support custom scope in authorization requests * fix(auth): default to provider's scope if not provided --- src/client/auth.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index b170cefc..e4941576 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -84,7 +84,14 @@ export class UnauthorizedError extends Error { */ export async function auth( provider: OAuthClientProvider, - { serverUrl, authorizationCode }: { serverUrl: string | URL, authorizationCode?: string }): Promise { + { serverUrl, + authorizationCode, + scope, + }: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + }): Promise { const metadata = await discoverOAuthMetadata(serverUrl); // Handle client registration if needed @@ -146,7 +153,7 @@ export async function auth( metadata, clientInformation, redirectUrl: provider.redirectUrl, - scope: provider.clientMetadata.scope + scope: scope || provider.clientMetadata.scope, }); await provider.saveCodeVerifier(codeVerifier); From a85fee407aa4e687e9ea21305e6ba559283cd39c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 5 May 2025 16:17:57 -0400 Subject: [PATCH 025/194] Add support for outputSchema and optional content fields in tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add outputSchema field to Tool type and RegisteredTool interface - Make content field optional in CallToolResult - Update ListToolsRequestSchema handler to include outputSchema in tool list responses - Add support for structuredContent in tool results - Update examples to handle optional content field - Add tests for new outputSchema and structuredContent functionality - Update ToolCallback documentation to clarify when to use structuredContent vs content This change enables tools to define structured output schemas and return structured JSON content, providing better type safety and validation for tool outputs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../client/parallelToolCallsClient.ts | 20 ++-- src/examples/client/simpleStreamableHttp.ts | 20 ++-- .../streamableHttpWithSseFallbackClient.ts | 20 ++-- src/server/mcp.test.ts | 100 +++++++++++++++++- src/server/mcp.ts | 11 +- src/types.ts | 7 +- 6 files changed, 154 insertions(+), 24 deletions(-) diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 3783992d..17a77872 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,13 +61,19 @@ async function main(): Promise { // Log the results from each tool call for (const [caller, result] of Object.entries(toolResults)) { console.log(`\n=== Tool result for ${caller} ===`); - result.content.forEach((item: { type: string; text?: string; }) => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach((item: { type: string; text?: string; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(` No content returned`); + } } // 3. Wait for all notifications (10 seconds) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c1501a57..2debacf6 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -341,13 +341,19 @@ async function callTool(name: string, args: Record): Promise { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(' No content returned'); + } } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 7646f0f7..06f35004 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,13 +173,19 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); + if (result.content) { + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else if (result.structuredContent) { + console.log(` Structured content: ${result.structuredContent}`); + } else { + console.log(' No content returned'); + } } catch (error) { console.log(`Error calling notification tool: ${error}`); } diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 5f50df68..433fc8f2 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -768,6 +768,104 @@ describe("tool()", () => { mcpServer.tool("tool2", () => ({ content: [] })); }); + test("should support tool with outputSchema and structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Register a tool with outputSchema + const registeredTool = mcpServer.tool( + "test", + "Test tool with structured output", + { + input: z.string(), + }, + async ({ input }) => ({ + // When outputSchema is defined, return structuredContent instead of content + structuredContent: JSON.stringify({ + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }), + }), + ); + + // Update the tool to add outputSchema + registeredTool.update({ + outputSchema: { + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["processedInput", "resultType", "timestamp"] + } + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].outputSchema).toEqual({ + type: "object", + properties: { + processedInput: { type: "string" }, + resultType: { type: "string" }, + timestamp: { type: "string", format: "date-time" } + }, + required: ["processedInput", "resultType", "timestamp"] + }); + + // Call the tool and verify it returns structuredContent + const result = await client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + input: "hello", + }, + }, + }, + CallToolResultSchema, + ); + + expect(result.structuredContent).toBeDefined(); + expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used + + const parsed = JSON.parse(result.structuredContent || "{}"); + expect(parsed.processedInput).toBe("hello"); + expect(parsed.resultType).toBe("structured"); + expect(parsed.timestamp).toBe("2023-01-01T00:00:00Z"); + }); + test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -871,7 +969,7 @@ describe("tool()", () => { expect(receivedRequestId).toBeDefined(); expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content[0].text).toContain("Received request ID:"); + expect(result.content && result.content[0].text).toContain("Received request ID:"); }); test("should provide sendNotification within tool call", async () => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 97459fb1..bf1abbe0 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -119,6 +119,7 @@ export class McpServer { strictUnions: true, }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, + outputSchema: tool.outputSchema, annotations: tool.annotations, }; }, @@ -696,6 +697,7 @@ export class McpServer { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), + outputSchema: undefined, annotations, callback: cb, enabled: true, @@ -709,6 +711,7 @@ export class McpServer { } if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + if (typeof updates.outputSchema !== "undefined") registeredTool.outputSchema = updates.outputSchema if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled @@ -896,6 +899,11 @@ export class ResourceTemplate { * Callback for a tool handler registered with Server.tool(). * * Parameters will include tool arguments, if applicable, as well as other request handler context. + * + * The callback should return: + * - `structuredContent` if the tool has an outputSchema defined + * - `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 @@ -908,12 +916,13 @@ export type ToolCallback = export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; + outputSchema?: Tool["outputSchema"]; annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void + update(updates: { name?: string | null, description?: string, paramsSchema?: Args, outputSchema?: Tool["outputSchema"], callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void remove(): void }; diff --git a/src/types.ts b/src/types.ts index 2ee0f752..2d5ae51d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -831,6 +831,10 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), }) .passthrough(), + /** + * A JSON Schema object defining the expected output for the tool. + */ + outputSchema: z.object({type: z.any()}).passthrough().optional(), /** * Optional additional tool information. */ @@ -858,7 +862,8 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({ export const CallToolResultSchema = ResultSchema.extend({ content: z.array( z.union([TextContentSchema, ImageContentSchema, AudioContentSchema, EmbeddedResourceSchema]), - ), + ).optional(), + structuredContent: z.string().optional(), isError: z.boolean().default(false).optional(), }); From cf12fe0d833ae12684448d49a8d26db4c8a42a37 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 5 May 2025 22:24:10 -0400 Subject: [PATCH 026/194] Add client-side validation for tool output schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache tool output schemas when listing tools - Validate structuredContent against outputSchema during callTool - Enforce that tools with outputSchema must return structuredContent - Add json-schema-to-zod dependency for schema conversion - Add comprehensive tests for validation scenarios 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 10 + package.json | 1 + src/client/index.test.ts | 452 +++++++++++++++++++++++++++++++++++++++ src/client/index.ts | 72 ++++++- 4 files changed, 533 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed986a69..425abd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -4879,6 +4880,15 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-to-zod": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", + "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index 233fd9e1..7c4701ae 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 5b4f332f..72ea63a8 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -12,6 +12,7 @@ import { InitializeRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, + CallToolRequestSchema, CreateMessageRequestSchema, ListRootsRequestSchema, ErrorCode, @@ -754,3 +755,454 @@ test("should handle request timeout", async () => { code: ErrorCode.RequestTimeout, }); }); + +describe('outputSchema validation', () => { + test('should validate structuredContent against outputSchema', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + return { + structuredContent: JSON.stringify({ result: 'success', count: 42 }), + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'test-tool' }); + expect(result.structuredContent).toBe('{"result":"success","count":42}'); + }); + + test('should throw error when structuredContent does not match schema', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' }, + }, + required: ['result', 'count'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: JSON.stringify({ result: 'success', count: 'not a number' }), + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); + + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return content instead of structuredContent + return { + content: [{ type: 'text', text: 'This should be structured content' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool test-tool has an output schema but did not return structured content/ + ); + }); + + test('should handle tools without outputSchema normally', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + // No outputSchema + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Return regular content + return { + content: [{ type: 'text', text: 'Normal response' }], + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should work normally without validation + const result = await client.callTool({ name: 'test-tool' }); + expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); + }); + + test('should handle complex JSON schema validation', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' }, + }, + required: ['created'], + }, + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'complex-tool') { + return { + structuredContent: JSON.stringify({ + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z', + }, + }), + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'complex-tool' }); + expect(result.structuredContent).toBeDefined(); + const parsedContent = JSON.parse(result.structuredContent as string); + expect(parsedContent.name).toBe('John Doe'); + expect(parsedContent.age).toBe(30); + }); + + test('should fail validation with additional properties when not allowed', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'strict-tool') { + // Return structured content with extra property + return { + structuredContent: JSON.stringify({ + name: 'John', + extraField: 'not allowed', + }), + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error due to additional property + await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts index a3edd0be..6fbae852 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -39,7 +39,12 @@ import { SubscribeRequest, SUPPORTED_PROTOCOL_VERSIONS, UnsubscribeRequest, + Tool, + ErrorCode, + McpError, } from "../types.js"; +import { z } from "zod"; +import { parseSchema } from "json-schema-to-zod"; export type ClientOptions = ProtocolOptions & { /** @@ -86,6 +91,8 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; + private _cachedTools: Map = new Map(); + private _cachedToolOutputSchemas: Map = new Map(); /** * Initializes this client with the given name and version information. @@ -413,22 +420,83 @@ export class Client< | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/call", params }, resultSchema, options, ); + + // Check if the tool has an outputSchema + const outputSchema = this._cachedToolOutputSchemas.get(params.name); + if (outputSchema) { + // If tool has outputSchema, it MUST return structuredContent + if (!result.structuredContent) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + + try { + // Parse the structured content as JSON + const contentData = JSON.parse(result.structuredContent as string); + + // Validate the content against the schema + const validationResult = outputSchema.safeParse(contentData); + + if (!validationResult.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.error.message}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return result; } async listTools( params?: ListToolsRequest["params"], options?: RequestOptions, ) { - return this.request( + const result = await this.request( { method: "tools/list", params }, ListToolsResultSchema, options, ); + + // Cache the tools and their output schemas for future validation + this._cachedTools.clear(); + this._cachedToolOutputSchemas.clear(); + + for (const tool of result.tools) { + this._cachedTools.set(tool.name, tool); + + // If the tool has an outputSchema, create and cache the Zod schema + if (tool.outputSchema) { + try { + const zodSchemaCode = parseSchema(tool.outputSchema); + // The library returns a string of Zod code, we need to evaluate it + // Using Function constructor to safely evaluate the Zod schema + const createSchema = new Function('z', `return ${zodSchemaCode}`); + const zodSchema = createSchema(z); + this._cachedToolOutputSchemas.set(tool.name, zodSchema); + } catch (error) { + console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); + } + } + } + + return result; } async sendRootsListChanged() { From 882e0b7d05c95af3ad3736612743dcdfe3e4745b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 6 May 2025 17:16:38 -0400 Subject: [PATCH 027/194] feat: update TypeScript SDK to implement draft spec changes for structured tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add outputSchema support to Tool interface with proper documentation - Split CallToolResult into structured and unstructured variants - Change structuredContent from string to object type - Add validation that tools without outputSchema cannot return structuredContent - Add validation that tools with outputSchema must return structuredContent - Update client to validate structured content as object (no JSON parsing) - Update tests to use object format for structuredContent - Add tests for new validation constraints - Update LATEST_PROTOCOL_VERSION to DRAFT-2025-v2 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/auth.test.ts | 2 +- src/client/index.test.ts | 153 ++++++++++++++++++++++++++++++++++++--- src/client/index.ts | 15 ++-- src/server/mcp.test.ts | 16 ++-- src/types.test.ts | 2 +- src/types.ts | 87 +++++++++++++++++++--- 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eba7074b..5aa8a5f3 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -39,7 +39,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-03-26" + "MCP-Protocol-Version": "DRAFT-2025-v2" }); }); diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 72ea63a8..25e13217 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -802,7 +802,7 @@ describe('outputSchema validation', () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'test-tool') { return { - structuredContent: JSON.stringify({ result: 'success', count: 42 }), + structuredContent: { result: 'success', count: 42 }, }; } throw new Error('Unknown tool'); @@ -825,7 +825,7 @@ describe('outputSchema validation', () => { // Call the tool - should validate successfully const result = await client.callTool({ name: 'test-tool' }); - expect(result.structuredContent).toBe('{"result":"success","count":42}'); + expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); }); test('should throw error when structuredContent does not match schema', async () => { @@ -874,7 +874,7 @@ describe('outputSchema validation', () => { if (request.params.name === 'test-tool') { // Return invalid structured content (count is string instead of number) return { - structuredContent: JSON.stringify({ result: 'success', count: 'not a number' }), + structuredContent: { result: 'success', count: 'not a number' }, }; } throw new Error('Unknown tool'); @@ -1094,7 +1094,7 @@ describe('outputSchema validation', () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'complex-tool') { return { - structuredContent: JSON.stringify({ + structuredContent: { name: 'John Doe', age: 30, active: true, @@ -1102,7 +1102,7 @@ describe('outputSchema validation', () => { metadata: { created: '2023-01-01T00:00:00Z', }, - }), + }, }; } throw new Error('Unknown tool'); @@ -1126,9 +1126,9 @@ describe('outputSchema validation', () => { // Call the tool - should validate successfully const result = await client.callTool({ name: 'complex-tool' }); expect(result.structuredContent).toBeDefined(); - const parsedContent = JSON.parse(result.structuredContent as string); - expect(parsedContent.name).toBe('John Doe'); - expect(parsedContent.age).toBe(30); + const structuredContent = result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + expect(structuredContent.age).toBe(30); }); test('should fail validation with additional properties when not allowed', async () => { @@ -1176,10 +1176,10 @@ describe('outputSchema validation', () => { if (request.params.name === 'strict-tool') { // Return structured content with extra property return { - structuredContent: JSON.stringify({ + structuredContent: { name: 'John', extraField: 'not allowed', - }), + }, }; } throw new Error('Unknown tool'); @@ -1205,4 +1205,137 @@ describe('outputSchema validation', () => { /Structured content does not match the tool's output schema/ ); }); + + test('should throw error when tool without outputSchema returns structuredContent', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + // No outputSchema defined + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Incorrectly return structuredContent for a tool without outputSchema + return { + structuredContent: { result: 'This should not be allowed' }, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool without outputSchema cannot return structuredContent/ + ); + }); + + test('should throw error when structuredContent is not an object', async () => { + const server = new Server({ + name: 'test-server', + version: '1.0.0', + }, { + capabilities: { + tools: {}, + }, + }); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async (request) => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0', + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {}, + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === 'test-tool') { + // Try to return a non-object value as structuredContent + return { + structuredContent: "This should be an object, not a string" as any, + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(); + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 6fbae852..945be0fa 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -438,11 +438,8 @@ export class Client< } try { - // Parse the structured content as JSON - const contentData = JSON.parse(result.structuredContent as string); - - // Validate the content against the schema - const validationResult = outputSchema.safeParse(contentData); + // Validate the structured content (which is already an object) against the schema + const validationResult = outputSchema.safeParse(result.structuredContent); if (!validationResult.success) { throw new McpError( @@ -459,6 +456,14 @@ export class Client< `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` ); } + } else { + // If tool doesn't have outputSchema, it MUST NOT return structuredContent + if (result.structuredContent) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool without outputSchema cannot return structuredContent` + ); + } } return result; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 433fc8f2..cacba7e2 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -795,11 +795,11 @@ describe("tool()", () => { }, async ({ input }) => ({ // When outputSchema is defined, return structuredContent instead of content - structuredContent: JSON.stringify({ + structuredContent: { processedInput: input, resultType: "structured", timestamp: "2023-01-01T00:00:00Z" - }), + }, }), ); @@ -860,10 +860,14 @@ describe("tool()", () => { expect(result.structuredContent).toBeDefined(); expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used - const parsed = JSON.parse(result.structuredContent || "{}"); - expect(parsed.processedInput).toBe("hello"); - expect(parsed.resultType).toBe("structured"); - expect(parsed.timestamp).toBe("2023-01-01T00:00:00Z"); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe("hello"); + expect(structuredContent.resultType).toBe("structured"); + expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z"); }); test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..9347acf0 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + expect(LATEST_PROTOCOL_VERSION).toBe("DRAFT-2025-v2"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); diff --git a/src/types.ts b/src/types.ts index 2d5ae51d..710212c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const LATEST_PROTOCOL_VERSION = "DRAFT-2025-v2"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2025-03-26", "2024-11-05", "2024-10-07", ]; @@ -829,12 +830,16 @@ export const ToolSchema = z .object({ type: z.literal("object"), properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), }) .passthrough(), /** - * A JSON Schema object defining the expected output for the tool. + * An optional JSON Schema object defining the structure of the tool's output. + * + * If set, a CallToolResult for this Tool MUST contain a structuredContent field whose contents validate against this schema. + * If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field. */ - outputSchema: z.object({type: z.any()}).passthrough().optional(), + outputSchema: z.optional(z.object({}).passthrough()), /** * Optional additional tool information. */ @@ -858,15 +863,76 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({ /** * The server's response to a tool call. + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. */ -export const CallToolResultSchema = ResultSchema.extend({ - content: z.array( - z.union([TextContentSchema, ImageContentSchema, AudioContentSchema, EmbeddedResourceSchema]), - ).optional(), - structuredContent: z.string().optional(), - isError: z.boolean().default(false).optional(), +export const ContentListSchema = z.array( + z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + EmbeddedResourceSchema, + ]), +); + +export const CallToolUnstructuredResultSchema = ResultSchema.extend({ + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool does not define an outputSchema, this field MUST be present in the result. + */ + content: ContentListSchema, + + /** + * Structured output must not be provided in an unstructured tool result. + */ + structuredContent: z.never().optional(), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError: z.optional(z.boolean()), }); +export const CallToolStructuredResultSchema = ResultSchema.extend({ + /** + * An object containing structured tool output. + * + * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: z.object({}).passthrough(), + + /** + * A list of content objects that represent the result of the tool call. + * + * If the Tool defines an outputSchema, this field MAY be present in the result. + * Tools may use this field to provide compatibility with older clients that do not support structured content. + * Clients that support structured content should ignore this field. + */ + content: z.optional(ContentListSchema), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError: z.optional(z.boolean()), +}); + +export const CallToolResultSchema = z.union([ + CallToolUnstructuredResultSchema, + CallToolStructuredResultSchema, +]); + /** * CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07. */ @@ -1317,6 +1383,9 @@ export type ToolAnnotations = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; +export type ContentList = Infer; +export type CallToolUnstructuredResult = Infer; +export type CallToolStructuredResult = Infer; export type CallToolResult = Infer; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer; From bb28098d81f15d4dd8b0a97cc1ed0790798ad924 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 7 May 2025 18:51:13 -0400 Subject: [PATCH 028/194] feat: add server-side support for tool outputSchema with backward compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update McpServer to support registering tools with outputSchema - Add automatic content generation from structuredContent for backward compatibility - Add validation to ensure proper usage of structuredContent vs content - Add comprehensive tests for outputSchema functionality - Add example servers demonstrating structured output usage - Update existing test to match new backward compatibility behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- OUTPUTSCHEMA_CHANGES.md | 171 ++++++++++++++ src/client/index.test.ts | 67 ------ src/client/index.ts | 35 +-- src/examples/server/mcpServerOutputSchema.ts | 236 +++++++++++++++++++ src/examples/server/outputSchema.ts | 209 ++++++++++++++++ src/server/mcp-outputschema.test.ts | 222 +++++++++++++++++ src/server/mcp.test.ts | 3 +- src/server/mcp.ts | 144 +++++++++-- 8 files changed, 987 insertions(+), 100 deletions(-) create mode 100644 OUTPUTSCHEMA_CHANGES.md create mode 100644 src/examples/server/mcpServerOutputSchema.ts create mode 100644 src/examples/server/outputSchema.ts create mode 100644 src/server/mcp-outputschema.test.ts diff --git a/OUTPUTSCHEMA_CHANGES.md b/OUTPUTSCHEMA_CHANGES.md new file mode 100644 index 00000000..22b7faee --- /dev/null +++ b/OUTPUTSCHEMA_CHANGES.md @@ -0,0 +1,171 @@ +# OutputSchema Support Implementation + +This document summarizes the changes made to support tools with `outputSchema` in the MCP TypeScript SDK. + +## Changes Made + +### Server-Side Changes + +#### 1. Tool Registration (mcp.ts) + +- Added support for parsing and storing `outputSchema` when registering tools +- Updated the `tool()` method to handle outputSchema parameter in various overload combinations +- Added new overloads to support tools with outputSchema: + ```typescript + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + ``` + +#### 2. Tool Listing + +- Modified `ListToolsResult` handler to include outputSchema in tool definitions +- Only includes outputSchema in the response if it's defined for the tool + +#### 3. Tool Execution + +- Updated `CallToolRequest` handler to validate structured content based on outputSchema +- Added automatic backward compatibility: + - If a tool has outputSchema and returns `structuredContent` but no `content`, the server automatically generates a text representation + - This ensures compatibility with clients that don't support structured content +- Added validation to ensure: + - Tools with outputSchema must return structuredContent (unless error) + - Tools without outputSchema must not return structuredContent + - Tools without outputSchema must return content + +#### 4. Backward Compatibility + +The implementation maintains full backward compatibility: +- Tools without outputSchema continue to work as before +- Tools with outputSchema can optionally provide both `structuredContent` and `content` +- If only `structuredContent` is provided, `content` is auto-generated as JSON + +### Client-Side Changes + +#### 1. Schema Caching and Validation (index.ts) + +- Added `_cachedTools` and `_cachedToolOutputSchemas` maps to cache tool definitions and their parsed Zod schemas +- The client converts JSON Schema to Zod schema using the `json-schema-to-zod` library for runtime validation +- Added dependency: `json-schema-to-zod` for converting JSON schemas to Zod schemas + +#### 2. Tool Listing + +- Modified `listTools` to parse and cache output schemas: + - When a tool has an outputSchema, the client converts it to a Zod schema + - Schemas are cached for validation during tool calls + - Handles errors gracefully with warning logs if schema parsing fails + +#### 3. Tool Execution + +- Enhanced `callTool` method with comprehensive validation: + - Tools with outputSchema must return `structuredContent` (validates this requirement) + - Tools without outputSchema must not return `structuredContent` + - Validates structured content against the cached Zod schema + - Provides detailed error messages when validation fails + +#### 4. Error Handling + +The client throws `McpError` with appropriate error codes: +- `ErrorCode.InvalidRequest` when required structured content is missing or unexpected +- `ErrorCode.InvalidParams` when structured content doesn't match the schema + +### Testing + +#### Server Tests + +Added comprehensive test suite (`mcp-outputschema.test.ts`) covering: +- Tool registration with outputSchema +- ListToolsResult including outputSchema +- Tool execution with structured content +- Automatic backward compatibility behavior +- Error cases and validation + +#### Client Tests + +Added tests in `index.test.ts` covering: +- Validation of structured content against output schemas +- Error handling when structured content doesn't match schema +- Error handling when tools with outputSchema don't return structured content +- Error handling when tools without outputSchema return structured content +- Complex JSON schema validation including nested objects, arrays, and strict mode +- Validation of additional properties when `additionalProperties: false` + +### Examples + +Created two example servers: +1. `outputSchema.ts` - Using the low-level Server API +2. `mcpServerOutputSchema.ts` - Using the high-level McpServer API + +These examples demonstrate: +- Tools with structured output (weather data, CSV processing, BMI calculation) +- Tools that return both structured and readable content +- Traditional tools without outputSchema for comparison + +## API Usage + +### Registering a tool with outputSchema: + +```typescript +server.tool( + "calculate_bmi", + "Calculate BMI given height and weight", + { + height_cm: z.number(), + weight_kg: z.number() + }, + { + type: "object", + properties: { + bmi: { type: "number" }, + category: { type: "string" } + }, + required: ["bmi", "category"] + }, + async ({ height_cm, weight_kg }) => { + // Calculate BMI... + return { + structuredContent: { + bmi: calculatedBmi, + category: bmiCategory + } + }; + } +); +``` + +### Tool callback return values: + +- For tools with outputSchema: Return `{ structuredContent: {...} }` +- For backward compatibility: Optionally include `{ structuredContent: {...}, content: [...] }` +- For tools without outputSchema: Return `{ content: [...] }` as before + +## Implementation Summary + +### Key Design Decisions + +1. **Backward Compatibility**: The server automatically generates `content` from `structuredContent` for clients that don't support structured output +2. **Schema Validation**: The client validates all structured content against the tool's output schema using Zod +3. **Caching**: The client caches parsed schemas to avoid re-parsing on every tool call +4. **Error Handling**: Both client and server validate the correct usage of `structuredContent` vs `content` based on whether a tool has an outputSchema + +### Implementation Notes + +1. **Server Side**: + - Automatically handles backward compatibility by serializing structuredContent to JSON + - Validates that tools properly use structuredContent vs content based on their outputSchema + - All existing tools continue to work without changes + +2. **Client Side**: + - Converts JSON Schema to Zod schemas for runtime validation + - Caches schemas for performance + - Provides detailed validation errors when structured content doesn't match schemas + - Enforces proper usage of structuredContent based on outputSchema presence + +3. **Compatibility**: + - The implementation follows the spec requirements + - Maintains full backward compatibility + - Provides a good developer experience with clear error messages + - Ensures both old and new clients can work with servers that support outputSchema \ No newline at end of file diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 25e13217..cd43a7e0 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1271,71 +1271,4 @@ describe('outputSchema validation', () => { ); }); - test('should throw error when structuredContent is not an object', async () => { - const server = new Server({ - name: 'test-server', - version: '1.0.0', - }, { - capabilities: { - tools: {}, - }, - }); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async (request) => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0', - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {}, - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' }, - }, - }, - }, - ], - })); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'test-tool') { - // Try to return a non-object value as structuredContent - return { - structuredContent: "This should be an object, not a string" as any, - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0', - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - server.connect(serverTransport), - ]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should throw validation error - await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(); - }); }); diff --git a/src/client/index.ts b/src/client/index.ts index 945be0fa..08640d45 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -429,32 +429,35 @@ export class Client< // Check if the tool has an outputSchema const outputSchema = this._cachedToolOutputSchemas.get(params.name); if (outputSchema) { - // If tool has outputSchema, it MUST return structuredContent - if (!result.structuredContent) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { throw new McpError( ErrorCode.InvalidRequest, `Tool ${params.name} has an output schema but did not return structured content` ); } - try { - // Validate the structured content (which is already an object) against the schema - const validationResult = outputSchema.safeParse(result.structuredContent); - - if (!validationResult.success) { + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content (which is already an object) against the schema + const validationResult = outputSchema.safeParse(result.structuredContent); + + if (!validationResult.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.error.message}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } throw new McpError( ErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.error.message}` + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` ); } - } catch (error) { - if (error instanceof McpError) { - throw error; - } - throw new McpError( - ErrorCode.InvalidParams, - `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` - ); } } else { // If tool doesn't have outputSchema, it MUST NOT return structuredContent diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts new file mode 100644 index 00000000..87820b6a --- /dev/null +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/** + * Example MCP server using the high-level McpServer API with outputSchema + * This demonstrates how to easily create tools with structured output + */ + +import { McpServer } from "../../server/mcp.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer( + { + name: "mcp-output-schema-example", + version: "1.0.0", + } +); + +// Define a tool with structured output - Weather data +server.tool( + "get_weather", + "Get weather information for a city", + { + city: z.string().describe("City name"), + country: z.string().describe("Country code (e.g., US, UK)") + }, + { + type: "object", + properties: { + temperature: { + type: "object", + properties: { + celsius: { type: "number" }, + fahrenheit: { type: "number" } + }, + required: ["celsius", "fahrenheit"] + }, + conditions: { + type: "string", + enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] + }, + humidity: { type: "number", minimum: 0, maximum: 100 }, + wind: { + type: "object", + properties: { + speed_kmh: { type: "number" }, + direction: { type: "string" } + }, + required: ["speed_kmh", "direction"] + } + }, + required: ["temperature", "conditions", "humidity", "wind"] + }, + async ({ city, country }: { city: string; country: string }) => { + // Parameters are available but not used in this example + void city; + 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)]; + + return { + structuredContent: { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + } + }; + } +); + +// Define a tool for data processing with structured output +server.tool( + "process_csv", + "Process CSV data and return statistics", + { + csv_data: z.string().describe("CSV data as a string"), + delimiter: z.string().default(",").describe("CSV delimiter") + }, + { + type: "object", + properties: { + row_count: { type: "integer" }, + column_count: { type: "integer" }, + headers: { + type: "array", + items: { type: "string" } + }, + data_types: { + type: "object", + additionalProperties: { + type: "string", + enum: ["number", "string", "date", "boolean"] + } + }, + summary: { + type: "object", + additionalProperties: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + mean: { type: "number" }, + count: { type: "integer" } + } + } + } + }, + required: ["row_count", "column_count", "headers", "data_types"] + }, + async ({ csv_data, delimiter }) => { + const lines = csv_data.trim().split('\n'); + const headers = lines[0].split(delimiter).map(h => h.trim()); + const data = lines.slice(1).map(line => line.split(delimiter).map(cell => cell.trim())); + + // Infer data types + const dataTypes: { [key: string]: string } = {}; + const summary: { [key: string]: unknown } = {}; + + headers.forEach((header, idx) => { + const values = data.map(row => row[idx]); + const numericValues = values.filter(v => !isNaN(Number(v)) && v !== ''); + + if (numericValues.length === values.length) { + dataTypes[header] = "number"; + const numbers = numericValues.map(Number); + summary[header] = { + min: Math.min(...numbers), + max: Math.max(...numbers), + mean: numbers.reduce((a, b) => a + b, 0) / numbers.length, + count: numbers.length + }; + } else { + dataTypes[header] = "string"; + } + }); + + return { + structuredContent: { + row_count: data.length, + column_count: headers.length, + headers, + data_types: dataTypes, + summary + } + }; + } +); + +// Traditional tool without outputSchema for comparison +server.tool( + "echo", + "Echo back the input message", + { + message: z.string() + }, + async ({ message }) => { + return { + content: [ + { + type: "text", + text: `Echo: ${message}` + } + ] + }; + } +); + +// Tool that can return both structured and unstructured content +server.tool( + "hybrid_tool", + "Tool that returns both structured and readable content", + { + data: z.array(z.number()).describe("Array of numbers to analyze") + }, + { + type: "object", + properties: { + stats: { + type: "object", + properties: { + mean: { type: "number" }, + median: { type: "number" }, + std_dev: { type: "number" } + } + } + }, + required: ["stats"] + }, + async ({ data }) => { + const mean = data.reduce((a, b) => a + b, 0) / data.length; + const sorted = [...data].sort((a, b) => a - b); + const median = sorted.length % 2 === 0 + ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 + : sorted[Math.floor(sorted.length / 2)]; + const variance = data.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / data.length; + const std_dev = Math.sqrt(variance); + + return { + structuredContent: { + stats: { + mean: Math.round(mean * 100) / 100, + median: Math.round(median * 100) / 100, + std_dev: Math.round(std_dev * 100) / 100 + } + }, + // Also provide human-readable content for backward compatibility + content: [ + { + type: "text", + text: `Analysis of ${data.length} numbers: +- Mean: ${Math.round(mean * 100) / 100} +- Median: ${Math.round(median * 100) / 100} +- Standard Deviation: ${Math.round(std_dev * 100) / 100}` + } + ] + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("McpServer Output Schema Example running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/outputSchema.ts b/src/examples/server/outputSchema.ts new file mode 100644 index 00000000..cb17b4b6 --- /dev/null +++ b/src/examples/server/outputSchema.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Example MCP server demonstrating tool outputSchema support + * This server exposes tools that return structured data + */ + +import { Server } from "../../server/index.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; + +const server = new Server( + { + name: "output-schema-example", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Tool with structured output +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "calculate_bmi", + description: "Calculate BMI given height and weight", + inputSchema: { + type: "object", + properties: { + height_cm: { type: "number", description: "Height in centimeters" }, + weight_kg: { type: "number", description: "Weight in kilograms" } + }, + required: ["height_cm", "weight_kg"] + }, + outputSchema: { + type: "object", + properties: { + bmi: { type: "number", description: "Body Mass Index" }, + category: { + type: "string", + enum: ["underweight", "normal", "overweight", "obese"], + description: "BMI category" + }, + healthy_weight_range: { + type: "object", + properties: { + min_kg: { type: "number" }, + max_kg: { type: "number" } + }, + required: ["min_kg", "max_kg"] + } + }, + required: ["bmi", "category", "healthy_weight_range"] + } + }, + { + name: "analyze_text", + description: "Analyze text and return structured insights", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "Text to analyze" } + }, + required: ["text"] + }, + outputSchema: { + type: "object", + properties: { + word_count: { type: "integer" }, + sentence_count: { type: "integer" }, + character_count: { type: "integer" }, + reading_time_minutes: { type: "number" }, + sentiment: { + type: "string", + enum: ["positive", "negative", "neutral"] + }, + key_phrases: { + type: "array", + items: { type: "string" } + } + }, + required: ["word_count", "sentence_count", "character_count", "reading_time_minutes"] + } + }, + { + name: "traditional_tool", + description: "A traditional tool without outputSchema", + inputSchema: { + type: "object", + properties: { + message: { type: "string" } + }, + required: ["message"] + } + } + ] +})); + +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + switch (request.params.name) { + case "calculate_bmi": { + const { height_cm, weight_kg } = request.params.arguments as { height_cm: number; weight_kg: number }; + + const height_m = height_cm / 100; + const bmi = weight_kg / (height_m * height_m); + + let category: string; + if (bmi < 18.5) category = "underweight"; + else if (bmi < 25) category = "normal"; + else if (bmi < 30) category = "overweight"; + else category = "obese"; + + // Calculate healthy weight range for normal BMI (18.5-24.9) + const min_healthy_bmi = 18.5; + const max_healthy_bmi = 24.9; + const min_healthy_weight = min_healthy_bmi * height_m * height_m; + const max_healthy_weight = max_healthy_bmi * height_m * height_m; + + // Return structured content matching the outputSchema + return { + structuredContent: { + bmi: Math.round(bmi * 10) / 10, + category, + healthy_weight_range: { + min_kg: Math.round(min_healthy_weight * 10) / 10, + max_kg: Math.round(max_healthy_weight * 10) / 10 + } + } + }; + } + + case "analyze_text": { + const { text } = request.params.arguments as { text: string }; + + // Simple text analysis + const words = text.trim().split(/\s+/); + const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + const wordsPerMinute = 200; // Average reading speed + + // Very simple sentiment analysis (for demo purposes) + const positiveWords = ["good", "great", "excellent", "happy", "positive", "amazing"]; + const negativeWords = ["bad", "poor", "terrible", "sad", "negative", "awful"]; + + let positiveCount = 0; + let negativeCount = 0; + words.forEach(word => { + if (positiveWords.includes(word.toLowerCase())) positiveCount++; + if (negativeWords.includes(word.toLowerCase())) negativeCount++; + }); + + let sentiment: string; + if (positiveCount > negativeCount) sentiment = "positive"; + else if (negativeCount > positiveCount) sentiment = "negative"; + else sentiment = "neutral"; + + // Extract key phrases (simple approach - just common bigrams) + const keyPhrases: string[] = []; + for (let i = 0; i < words.length - 1; i++) { + if (words[i].length > 3 && words[i + 1].length > 3) { + keyPhrases.push(`${words[i]} ${words[i + 1]}`); + } + } + + return { + structuredContent: { + word_count: words.length, + sentence_count: sentences.length, + character_count: text.length, + reading_time_minutes: Math.round((words.length / wordsPerMinute) * 10) / 10, + sentiment, + key_phrases: keyPhrases.slice(0, 5) // Top 5 phrases + } + }; + } + + case "traditional_tool": { + const { message } = request.params.arguments as { message: string }; + + // Traditional tool returns content array + return { + content: [ + { + type: "text", + text: `Processed message: ${message.toUpperCase()}` + } + ] + }; + } + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Output Schema Example Server running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/server/mcp-outputschema.test.ts b/src/server/mcp-outputschema.test.ts new file mode 100644 index 00000000..62ea74d3 --- /dev/null +++ b/src/server/mcp-outputschema.test.ts @@ -0,0 +1,222 @@ +import { McpServer } from './mcp.js'; +import { Client } from '../client/index.js'; +import { InMemoryTransport } from '../inMemory.js'; +import { z } from 'zod'; + +describe('McpServer outputSchema support', () => { + let server: McpServer; + let client: Client; + let serverTransport: InMemoryTransport; + let clientTransport: InMemoryTransport; + + beforeEach(async () => { + server = new McpServer({ name: 'test', version: '1.0' }); + client = new Client({ name: 'test-client', version: '1.0' }); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + describe('tool registration with outputSchema', () => { + it('should register a tool with outputSchema', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'] + }; + + const tool = server.tool( + 'test-tool', + 'A test tool', + { input: z.string() }, + outputSchema, + () => ({ structuredContent: { result: 'test', count: 42 } }) + ); + + expect(tool.outputSchema).toEqual(outputSchema); + + // Connect after registering the tool + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + it('should include outputSchema in ListToolsResult', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ structuredContent: { result: 'test' } }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.listTools(); + expect(result.tools[0].outputSchema).toEqual(outputSchema); + }); + }); + + describe('tool execution with outputSchema', () => { + it('should return structuredContent and auto-generate content for backward compatibility', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ + structuredContent: { result: 'test', count: 42 } + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); + + expect(result.structuredContent).toEqual({ result: 'test', count: 42 }); + expect(result.content).toEqual([{ + type: 'text', + text: JSON.stringify({ result: 'test', count: 42 }, null, 2) + }]); + }); + + it('should preserve both content and structuredContent if tool provides both', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'structured-tool', + { input: z.string() }, + outputSchema, + () => ({ + structuredContent: { result: 'test' }, + content: [{ type: 'text', text: 'Custom text' }] + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); + + expect(result.structuredContent).toEqual({ result: 'test' }); + expect(result.content).toEqual([{ type: 'text', text: 'Custom text' }]); + }); + + it('should throw error if tool with outputSchema returns no structuredContent', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'broken-tool', + { input: z.string() }, + outputSchema, + () => ({ + content: [{ type: 'text', text: 'No structured content' }] + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) + .rejects.toThrow('has outputSchema but returned no structuredContent'); + }); + + it('should throw error if tool without outputSchema returns structuredContent', async () => { + server.tool( + 'broken-tool', + { input: z.string() }, + () => ({ + structuredContent: { result: 'test' } + }) + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Call listTools first (but in this case the tool has no outputSchema) + await client.listTools(); + + await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) + .rejects.toThrow('has no outputSchema but returned structuredContent'); + }); + + it('should handle error results properly for tools with outputSchema', async () => { + const outputSchema = { + type: 'object', + properties: { + result: { type: 'string' } + } + }; + + server.tool( + 'error-tool', + { input: z.string() }, + outputSchema, + () => { + throw new Error('Tool error'); + } + ); + + // Now connect + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Need to call listTools first to cache the outputSchema + await client.listTools(); + + const result = await client.callTool({ name: 'error-tool', arguments: { input: 'test' } }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ + type: 'text', + text: 'Tool error' + }]); + expect(result.structuredContent).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index cacba7e2..c617b16f 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -858,7 +858,8 @@ describe("tool()", () => { ); expect(result.structuredContent).toBeDefined(); - expect(result.content).toBeUndefined(); // Should not have content when structuredContent is used + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); const structuredContent = result.structuredContent as { processedInput: string; diff --git a/src/server/mcp.ts b/src/server/mcp.ts index bf1abbe0..5fa8db97 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -111,7 +111,7 @@ export class McpServer { ([, tool]) => tool.enabled, ).map( ([name, tool]): Tool => { - return { + const toolDefinition: Tool = { name, description: tool.description, inputSchema: tool.inputSchema @@ -119,9 +119,15 @@ export class McpServer { strictUnions: true, }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, - outputSchema: tool.outputSchema, annotations: tool.annotations, }; + + // Only include outputSchema if it's defined + if (tool.outputSchema) { + toolDefinition.outputSchema = tool.outputSchema; + } + + return toolDefinition; }, ), }), @@ -145,6 +151,8 @@ export class McpServer { ); } + let result: CallToolResult; + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -159,9 +167,9 @@ export class McpServer { const args = parseResult.data; const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)); + result = await Promise.resolve(cb(args, extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -174,9 +182,9 @@ export class McpServer { } else { const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(extra)); + result = await Promise.resolve(cb(extra)); } catch (error) { - return { + result = { content: [ { type: "text", @@ -187,6 +195,46 @@ export class McpServer { }; } } + + // Handle structured output and backward compatibility + if (tool.outputSchema) { + // Tool has outputSchema, so result must have structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has outputSchema but returned no structuredContent`, + ); + } + + // For backward compatibility, if structuredContent is provided but no content, + // automatically serialize the structured content to text + if (result.structuredContent && !result.content) { + result.content = [ + { + type: "text", + text: JSON.stringify(result.structuredContent, null, 2), + }, + ]; + } + } else { + // Tool has no outputSchema + if (result.structuredContent) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ); + } + + // Tool must have content if no outputSchema + if (!result.content && !result.isError) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema and must return content`, + ); + } + } + + return result; }, ); @@ -656,6 +704,50 @@ export class McpServer { cb: ToolCallback, ): RegisteredTool; + /** + * Registers a tool with output schema. + */ + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description and output schema. + */ + tool( + name: string, + description: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with parameter schema, output schema, and annotations. + */ + tool( + name: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description, parameter schema, output schema, and annotations. + */ + tool( + name: string, + description: string, + paramsSchema: Args, + outputSchema: Tool["outputSchema"], + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); @@ -667,6 +759,7 @@ export class McpServer { } let paramsSchema: ZodRawShape | undefined; + let outputSchema: Tool["outputSchema"] | undefined; let annotations: ToolAnnotations | undefined; // Handle the different overload combinations @@ -678,17 +771,36 @@ export class McpServer { // We have a params schema as the first arg paramsSchema = rest.shift() as ZodRawShape; - // Check if the next arg is potentially annotations - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { - // Case: tool(name, paramsSchema, annotations, cb) - // Or: tool(name, description, paramsSchema, annotations, cb) - annotations = rest.shift() as ToolAnnotations; + // Check if the next arg is potentially annotations or outputSchema + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null) { + const nextArg = rest[0]; + + // Check if it's a JSON Schema (outputSchema) + if (typeof nextArg === "object" && "type" in nextArg) { + outputSchema = rest.shift() as Tool["outputSchema"]; + + // Check if there's still an annotations object + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + annotations = rest.shift() as ToolAnnotations; + } + } else if (!(isZodRawShape(nextArg))) { + // It's annotations + annotations = rest.shift() as ToolAnnotations; + } } } else if (typeof firstArg === "object" && firstArg !== null) { - // Not a ZodRawShape, so must be annotations in this position - // Case: tool(name, annotations, cb) - // Or: tool(name, description, annotations, cb) - annotations = rest.shift() as ToolAnnotations; + // Check if it's a JSON Schema (outputSchema) + if ("type" in firstArg) { + outputSchema = rest.shift() as Tool["outputSchema"]; + + // Check if there's still an annotations object + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + annotations = rest.shift() as ToolAnnotations; + } + } else { + // It's annotations + annotations = rest.shift() as ToolAnnotations; + } } } @@ -697,7 +809,7 @@ export class McpServer { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), - outputSchema: undefined, + outputSchema, annotations, callback: cb, enabled: true, From 27d66ef031a06feb0969881066b24a66d14fdefc Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 9 May 2025 23:30:06 -0400 Subject: [PATCH 029/194] back out protocol version change --- src/client/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 5aa8a5f3..eba7074b 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -39,7 +39,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "DRAFT-2025-v2" + "MCP-Protocol-Version": "2025-03-26" }); }); From 37dcf753afbfa7da2e87adf6c5e6cd7d51eef6a7 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 9 May 2025 23:30:37 -0400 Subject: [PATCH 030/194] remove OUTPUTSCHEMA_CHANGES.md --- OUTPUTSCHEMA_CHANGES.md | 171 ---------------------------------------- 1 file changed, 171 deletions(-) delete mode 100644 OUTPUTSCHEMA_CHANGES.md diff --git a/OUTPUTSCHEMA_CHANGES.md b/OUTPUTSCHEMA_CHANGES.md deleted file mode 100644 index 22b7faee..00000000 --- a/OUTPUTSCHEMA_CHANGES.md +++ /dev/null @@ -1,171 +0,0 @@ -# OutputSchema Support Implementation - -This document summarizes the changes made to support tools with `outputSchema` in the MCP TypeScript SDK. - -## Changes Made - -### Server-Side Changes - -#### 1. Tool Registration (mcp.ts) - -- Added support for parsing and storing `outputSchema` when registering tools -- Updated the `tool()` method to handle outputSchema parameter in various overload combinations -- Added new overloads to support tools with outputSchema: - ```typescript - tool( - name: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - cb: ToolCallback, - ): RegisteredTool; - ``` - -#### 2. Tool Listing - -- Modified `ListToolsResult` handler to include outputSchema in tool definitions -- Only includes outputSchema in the response if it's defined for the tool - -#### 3. Tool Execution - -- Updated `CallToolRequest` handler to validate structured content based on outputSchema -- Added automatic backward compatibility: - - If a tool has outputSchema and returns `structuredContent` but no `content`, the server automatically generates a text representation - - This ensures compatibility with clients that don't support structured content -- Added validation to ensure: - - Tools with outputSchema must return structuredContent (unless error) - - Tools without outputSchema must not return structuredContent - - Tools without outputSchema must return content - -#### 4. Backward Compatibility - -The implementation maintains full backward compatibility: -- Tools without outputSchema continue to work as before -- Tools with outputSchema can optionally provide both `structuredContent` and `content` -- If only `structuredContent` is provided, `content` is auto-generated as JSON - -### Client-Side Changes - -#### 1. Schema Caching and Validation (index.ts) - -- Added `_cachedTools` and `_cachedToolOutputSchemas` maps to cache tool definitions and their parsed Zod schemas -- The client converts JSON Schema to Zod schema using the `json-schema-to-zod` library for runtime validation -- Added dependency: `json-schema-to-zod` for converting JSON schemas to Zod schemas - -#### 2. Tool Listing - -- Modified `listTools` to parse and cache output schemas: - - When a tool has an outputSchema, the client converts it to a Zod schema - - Schemas are cached for validation during tool calls - - Handles errors gracefully with warning logs if schema parsing fails - -#### 3. Tool Execution - -- Enhanced `callTool` method with comprehensive validation: - - Tools with outputSchema must return `structuredContent` (validates this requirement) - - Tools without outputSchema must not return `structuredContent` - - Validates structured content against the cached Zod schema - - Provides detailed error messages when validation fails - -#### 4. Error Handling - -The client throws `McpError` with appropriate error codes: -- `ErrorCode.InvalidRequest` when required structured content is missing or unexpected -- `ErrorCode.InvalidParams` when structured content doesn't match the schema - -### Testing - -#### Server Tests - -Added comprehensive test suite (`mcp-outputschema.test.ts`) covering: -- Tool registration with outputSchema -- ListToolsResult including outputSchema -- Tool execution with structured content -- Automatic backward compatibility behavior -- Error cases and validation - -#### Client Tests - -Added tests in `index.test.ts` covering: -- Validation of structured content against output schemas -- Error handling when structured content doesn't match schema -- Error handling when tools with outputSchema don't return structured content -- Error handling when tools without outputSchema return structured content -- Complex JSON schema validation including nested objects, arrays, and strict mode -- Validation of additional properties when `additionalProperties: false` - -### Examples - -Created two example servers: -1. `outputSchema.ts` - Using the low-level Server API -2. `mcpServerOutputSchema.ts` - Using the high-level McpServer API - -These examples demonstrate: -- Tools with structured output (weather data, CSV processing, BMI calculation) -- Tools that return both structured and readable content -- Traditional tools without outputSchema for comparison - -## API Usage - -### Registering a tool with outputSchema: - -```typescript -server.tool( - "calculate_bmi", - "Calculate BMI given height and weight", - { - height_cm: z.number(), - weight_kg: z.number() - }, - { - type: "object", - properties: { - bmi: { type: "number" }, - category: { type: "string" } - }, - required: ["bmi", "category"] - }, - async ({ height_cm, weight_kg }) => { - // Calculate BMI... - return { - structuredContent: { - bmi: calculatedBmi, - category: bmiCategory - } - }; - } -); -``` - -### Tool callback return values: - -- For tools with outputSchema: Return `{ structuredContent: {...} }` -- For backward compatibility: Optionally include `{ structuredContent: {...}, content: [...] }` -- For tools without outputSchema: Return `{ content: [...] }` as before - -## Implementation Summary - -### Key Design Decisions - -1. **Backward Compatibility**: The server automatically generates `content` from `structuredContent` for clients that don't support structured output -2. **Schema Validation**: The client validates all structured content against the tool's output schema using Zod -3. **Caching**: The client caches parsed schemas to avoid re-parsing on every tool call -4. **Error Handling**: Both client and server validate the correct usage of `structuredContent` vs `content` based on whether a tool has an outputSchema - -### Implementation Notes - -1. **Server Side**: - - Automatically handles backward compatibility by serializing structuredContent to JSON - - Validates that tools properly use structuredContent vs content based on their outputSchema - - All existing tools continue to work without changes - -2. **Client Side**: - - Converts JSON Schema to Zod schemas for runtime validation - - Caches schemas for performance - - Provides detailed validation errors when structured content doesn't match schemas - - Enforces proper usage of structuredContent based on outputSchema presence - -3. **Compatibility**: - - The implementation follows the spec requirements - - Maintains full backward compatibility - - Provides a good developer experience with clear error messages - - Ensures both old and new clients can work with servers that support outputSchema \ No newline at end of file From 9686fc2eadf755f8f7584ecc42b3cdcb0c71ee43 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Sat, 10 May 2025 00:14:45 -0400 Subject: [PATCH 031/194] formatting, more version rollbacks --- src/client/index.ts | 6 ++-- src/examples/server/mcpServerOutputSchema.ts | 18 +++++----- src/examples/server/outputSchema.ts | 38 ++++++++++---------- src/server/mcp-outputschema.test.ts | 10 +++--- src/server/mcp.test.ts | 4 +-- src/server/mcp.ts | 14 ++++---- src/types.test.ts | 2 +- src/types.ts | 11 +++--- 8 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 08640d45..98a0190c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -442,7 +442,7 @@ export class Client< try { // Validate the structured content (which is already an object) against the schema const validationResult = outputSchema.safeParse(result.structuredContent); - + if (!validationResult.success) { throw new McpError( ErrorCode.InvalidParams, @@ -485,10 +485,10 @@ export class Client< // Cache the tools and their output schemas for future validation this._cachedTools.clear(); this._cachedToolOutputSchemas.clear(); - + for (const tool of result.tools) { this._cachedTools.set(tool.name, tool); - + // If the tool has an outputSchema, create and cache the Zod schema if (tool.outputSchema) { try { diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 87820b6a..cb845851 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -34,7 +34,7 @@ server.tool( }, required: ["celsius", "fahrenheit"] }, - conditions: { + conditions: { type: "string", enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] }, @@ -57,7 +57,7 @@ server.tool( // 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)]; - + return { structuredContent: { temperature: { @@ -118,15 +118,15 @@ server.tool( const lines = csv_data.trim().split('\n'); const headers = lines[0].split(delimiter).map(h => h.trim()); const data = lines.slice(1).map(line => line.split(delimiter).map(cell => cell.trim())); - + // Infer data types const dataTypes: { [key: string]: string } = {}; const summary: { [key: string]: unknown } = {}; - + headers.forEach((header, idx) => { const values = data.map(row => row[idx]); const numericValues = values.filter(v => !isNaN(Number(v)) && v !== ''); - + if (numericValues.length === values.length) { dataTypes[header] = "number"; const numbers = numericValues.map(Number); @@ -140,7 +140,7 @@ server.tool( dataTypes[header] = "string"; } }); - + return { structuredContent: { row_count: data.length, @@ -174,7 +174,7 @@ server.tool( // Tool that can return both structured and unstructured content server.tool( - "hybrid_tool", + "hybrid_tool", "Tool that returns both structured and readable content", { data: z.array(z.number()).describe("Array of numbers to analyze") @@ -196,12 +196,12 @@ server.tool( async ({ data }) => { const mean = data.reduce((a, b) => a + b, 0) / data.length; const sorted = [...data].sort((a, b) => a - b); - const median = sorted.length % 2 === 0 + const median = sorted.length % 2 === 0 ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 : sorted[Math.floor(sorted.length / 2)]; const variance = data.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / data.length; const std_dev = Math.sqrt(variance); - + return { structuredContent: { stats: { diff --git a/src/examples/server/outputSchema.ts b/src/examples/server/outputSchema.ts index cb17b4b6..8530a760 100644 --- a/src/examples/server/outputSchema.ts +++ b/src/examples/server/outputSchema.ts @@ -38,8 +38,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "object", properties: { bmi: { type: "number", description: "Body Mass Index" }, - category: { - type: "string", + category: { + type: "string", enum: ["underweight", "normal", "overweight", "obese"], description: "BMI category" }, @@ -102,22 +102,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) switch (request.params.name) { case "calculate_bmi": { const { height_cm, weight_kg } = request.params.arguments as { height_cm: number; weight_kg: number }; - + const height_m = height_cm / 100; const bmi = weight_kg / (height_m * height_m); - + let category: string; if (bmi < 18.5) category = "underweight"; else if (bmi < 25) category = "normal"; else if (bmi < 30) category = "overweight"; else category = "obese"; - + // Calculate healthy weight range for normal BMI (18.5-24.9) const min_healthy_bmi = 18.5; const max_healthy_bmi = 24.9; const min_healthy_weight = min_healthy_bmi * height_m * height_m; const max_healthy_weight = max_healthy_bmi * height_m * height_m; - + // Return structured content matching the outputSchema return { structuredContent: { @@ -130,31 +130,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) } }; } - + case "analyze_text": { const { text } = request.params.arguments as { text: string }; - + // Simple text analysis const words = text.trim().split(/\s+/); const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); const wordsPerMinute = 200; // Average reading speed - + // Very simple sentiment analysis (for demo purposes) const positiveWords = ["good", "great", "excellent", "happy", "positive", "amazing"]; const negativeWords = ["bad", "poor", "terrible", "sad", "negative", "awful"]; - + let positiveCount = 0; let negativeCount = 0; words.forEach(word => { if (positiveWords.includes(word.toLowerCase())) positiveCount++; if (negativeWords.includes(word.toLowerCase())) negativeCount++; }); - + let sentiment: string; if (positiveCount > negativeCount) sentiment = "positive"; else if (negativeCount > positiveCount) sentiment = "negative"; else sentiment = "neutral"; - + // Extract key phrases (simple approach - just common bigrams) const keyPhrases: string[] = []; for (let i = 0; i < words.length - 1; i++) { @@ -162,7 +162,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) keyPhrases.push(`${words[i]} ${words[i + 1]}`); } } - + return { structuredContent: { word_count: words.length, @@ -174,21 +174,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) } }; } - + case "traditional_tool": { const { message } = request.params.arguments as { message: string }; - + // Traditional tool returns content array return { content: [ - { - type: "text", - text: `Processed message: ${message.toUpperCase()}` + { + type: "text", + text: `Processed message: ${message.toUpperCase()}` } ] }; } - + default: throw new McpError( ErrorCode.MethodNotFound, diff --git a/src/server/mcp-outputschema.test.ts b/src/server/mcp-outputschema.test.ts index 62ea74d3..e59bd563 100644 --- a/src/server/mcp-outputschema.test.ts +++ b/src/server/mcp-outputschema.test.ts @@ -12,7 +12,7 @@ describe('McpServer outputSchema support', () => { beforeEach(async () => { server = new McpServer({ name: 'test', version: '1.0' }); client = new Client({ name: 'test-client', version: '1.0' }); - + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); }); @@ -85,7 +85,7 @@ describe('McpServer outputSchema support', () => { 'structured-tool', { input: z.string() }, outputSchema, - () => ({ + () => ({ structuredContent: { result: 'test', count: 42 } }) ); @@ -118,7 +118,7 @@ describe('McpServer outputSchema support', () => { 'structured-tool', { input: z.string() }, outputSchema, - () => ({ + () => ({ structuredContent: { result: 'test' }, content: [{ type: 'text', text: 'Custom text' }] }) @@ -149,7 +149,7 @@ describe('McpServer outputSchema support', () => { 'broken-tool', { input: z.string() }, outputSchema, - () => ({ + () => ({ content: [{ type: 'text', text: 'No structured content' }] }) ); @@ -169,7 +169,7 @@ describe('McpServer outputSchema support', () => { server.tool( 'broken-tool', { input: z.string() }, - () => ({ + () => ({ structuredContent: { result: 'test' } }) ); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c617b16f..b9561739 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -860,8 +860,8 @@ describe("tool()", () => { expect(result.structuredContent).toBeDefined(); // For backward compatibility, content is auto-generated from structuredContent expect(result.content).toBeDefined(); - - const structuredContent = result.structuredContent as { + + const structuredContent = result.structuredContent as { processedInput: string; resultType: string; timestamp: string; diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5fa8db97..faf8faaf 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -121,12 +121,12 @@ export class McpServer { : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, }; - + // Only include outputSchema if it's defined if (tool.outputSchema) { toolDefinition.outputSchema = tool.outputSchema; } - + return toolDefinition; }, ), @@ -205,7 +205,7 @@ export class McpServer { `Tool ${request.params.name} has outputSchema but returned no structuredContent`, ); } - + // For backward compatibility, if structuredContent is provided but no content, // automatically serialize the structured content to text if (result.structuredContent && !result.content) { @@ -224,7 +224,7 @@ export class McpServer { `Tool ${request.params.name} has no outputSchema but returned structuredContent`, ); } - + // Tool must have content if no outputSchema if (!result.content && !result.isError) { throw new McpError( @@ -774,11 +774,11 @@ export class McpServer { // Check if the next arg is potentially annotations or outputSchema if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null) { const nextArg = rest[0]; - + // Check if it's a JSON Schema (outputSchema) if (typeof nextArg === "object" && "type" in nextArg) { outputSchema = rest.shift() as Tool["outputSchema"]; - + // Check if there's still an annotations object if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { annotations = rest.shift() as ToolAnnotations; @@ -1011,7 +1011,7 @@ export class ResourceTemplate { * Callback for a tool handler registered with Server.tool(). * * Parameters will include tool arguments, if applicable, as well as other request handler context. - * + * * The callback should return: * - `structuredContent` if the tool has an outputSchema defined * - `content` if the tool does not have an outputSchema diff --git a/src/types.test.ts b/src/types.test.ts index 9347acf0..0fbc003d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("DRAFT-2025-v2"); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); diff --git a/src/types.ts b/src/types.ts index 710212c4..dc9ab5df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,8 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "DRAFT-2025-v2"; +export const LATEST_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, - "2025-03-26", "2024-11-05", "2024-10-07", ]; @@ -889,12 +888,12 @@ export const CallToolUnstructuredResultSchema = ResultSchema.extend({ * If the Tool does not define an outputSchema, this field MUST be present in the result. */ content: ContentListSchema, - + /** * Structured output must not be provided in an unstructured tool result. */ structuredContent: z.never().optional(), - + /** * Whether the tool call ended in an error. * @@ -910,7 +909,7 @@ export const CallToolStructuredResultSchema = ResultSchema.extend({ * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. */ structuredContent: z.object({}).passthrough(), - + /** * A list of content objects that represent the result of the tool call. * @@ -919,7 +918,7 @@ export const CallToolStructuredResultSchema = ResultSchema.extend({ * Clients that support structured content should ignore this field. */ content: z.optional(ContentListSchema), - + /** * Whether the tool call ended in an error. * From 6079cd830708431f9cd19e7780ad3e63ec31969c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 13 May 2025 08:44:03 -0400 Subject: [PATCH 032/194] - add new tool() api and tests - fix schema validation - comment headers on tests --- package-lock.json | 10 - src/client/index.test.ts | 64 ++- src/client/index.ts | 50 ++- src/examples/server/mcpServerOutputSchema.ts | 2 +- src/server/mcp-outputschema.test.ts | 222 ---------- src/server/mcp.test.ts | 433 +++++++++++++++++-- src/server/mcp.ts | 193 ++++----- src/types.ts | 9 +- 8 files changed, 580 insertions(+), 403 deletions(-) delete mode 100644 src/server/mcp-outputschema.test.ts diff --git a/package-lock.json b/package-lock.json index 425abd47..ed986a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -4880,15 +4879,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/json-schema-to-zod": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", - "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", - "license": "ISC", - "bin": { - "json-schema-to-zod": "dist/cjs/cli.js" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index cd43a7e0..d377af86 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -21,6 +21,9 @@ import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; +/*** + * Test: Initialize with Matching Protocol Version + */ test("should initialize with matching protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -76,6 +79,9 @@ test("should initialize with matching protocol version", async () => { expect(client.getInstructions()).toEqual("test instructions"); }); +/*** + * Test: Initialize with Supported Older Protocol Version + */ test("should initialize with supported older protocol version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const clientTransport: Transport = { @@ -124,6 +130,9 @@ test("should initialize with supported older protocol version", async () => { expect(client.getInstructions()).toBeUndefined(); }); +/*** + * Test: Reject Unsupported Protocol Version + */ test("should reject unsupported protocol version", async () => { const clientTransport: Transport = { start: jest.fn().mockResolvedValue(undefined), @@ -166,6 +175,9 @@ test("should reject unsupported protocol version", async () => { expect(clientTransport.close).toHaveBeenCalled(); }); +/*** + * Test: Connect New Client to Old Supported Server Version + */ test("should connect new client to old, supported server version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -229,6 +241,9 @@ test("should connect new client to old, supported server version", async () => { }); }); +/*** + * Test: Version Negotiation with Old Client and Newer Server + */ test("should negotiate version when client is old, and newer server supports its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const server = new Server( @@ -292,6 +307,9 @@ test("should negotiate version when client is old, and newer server supports its }); }); +/*** + * Test: Throw when Old Client and Server Version Mismatch + */ test("should throw when client is old, and server doesn't support its version", async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const FUTURE_VERSION = "FUTURE_VERSION"; @@ -354,6 +372,9 @@ test("should throw when client is old, and server doesn't support its version", }); +/*** + * Test: Respect Server Capabilities + */ test("should respect server capabilities", async () => { const server = new Server( { @@ -434,6 +455,9 @@ test("should respect server capabilities", async () => { ).rejects.toThrow("Server does not support completions"); }); +/*** + * Test: Respect Client Notification Capabilities + */ test("should respect client notification capabilities", async () => { const server = new Server( { @@ -490,6 +514,9 @@ test("should respect client notification capabilities", async () => { ); }); +/*** + * Test: Respect Server Notification Capabilities + */ test("should respect server notification capabilities", async () => { const server = new Server( { @@ -536,6 +563,9 @@ test("should respect server notification capabilities", async () => { ); }); +/*** + * Test: Only Allow setRequestHandler for Declared Capabilities + */ test("should only allow setRequestHandler for declared capabilities", () => { const client = new Client( { @@ -567,9 +597,10 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); -/* - Test that custom request/notification/result schemas can be used with the Client class. - */ +/*** + * Test: Type Checking + * Test that custom request/notification/result schemas can be used with the Client class. + */ test("should typecheck", () => { const GetWeatherRequestSchema = RequestSchema.extend({ method: z.literal("weather/get"), @@ -646,6 +677,9 @@ test("should typecheck", () => { }); }); +/*** + * Test: Handle Client Cancelling a Request + */ test("should handle client cancelling a request", async () => { const server = new Server( { @@ -701,6 +735,9 @@ test("should handle client cancelling a request", async () => { await expect(listResourcesPromise).rejects.toBe("Cancelled by test"); }); +/*** + * Test: Handle Request Timeout + */ test("should handle request timeout", async () => { const server = new Server( { @@ -757,6 +794,9 @@ test("should handle request timeout", async () => { }); describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ test('should validate structuredContent against outputSchema', async () => { const server = new Server({ name: 'test-server', @@ -828,6 +868,9 @@ describe('outputSchema validation', () => { expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); }); + /*** + * Test: Throw Error when structuredContent Does Not Match Schema + */ test('should throw error when structuredContent does not match schema', async () => { const server = new Server({ name: 'test-server', @@ -901,6 +944,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Throw Error when Tool with outputSchema Returns No structuredContent + */ test('should throw error when tool with outputSchema returns no structuredContent', async () => { const server = new Server({ name: 'test-server', @@ -972,6 +1018,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Handle Tools Without outputSchema Normally + */ test('should handle tools without outputSchema normally', async () => { const server = new Server({ name: 'test-server', @@ -1036,6 +1085,9 @@ describe('outputSchema validation', () => { expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); }); + /*** + * Test: Handle Complex JSON Schema Validation + */ test('should handle complex JSON schema validation', async () => { const server = new Server({ name: 'test-server', @@ -1131,6 +1183,9 @@ describe('outputSchema validation', () => { expect(structuredContent.age).toBe(30); }); + /*** + * Test: Fail Validation with Additional Properties When Not Allowed + */ test('should fail validation with additional properties when not allowed', async () => { const server = new Server({ name: 'test-server', @@ -1206,6 +1261,9 @@ describe('outputSchema validation', () => { ); }); + /*** + * Test: Throw Error when Tool Without outputSchema Returns structuredContent + */ test('should throw error when tool without outputSchema returns structuredContent', async () => { const server = new Server({ name: 'test-server', diff --git a/src/client/index.ts b/src/client/index.ts index 98a0190c..c4aed3a4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -44,7 +44,7 @@ import { McpError, } from "../types.js"; import { z } from "zod"; -import { parseSchema } from "json-schema-to-zod"; +import { JsonSchema, parseSchema } from "json-schema-to-zod"; export type ClientOptions = ProtocolOptions & { /** @@ -91,7 +91,6 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; - private _cachedTools: Map = new Map(); private _cachedToolOutputSchemas: Map = new Map(); /** @@ -427,7 +426,7 @@ export class Client< ); // Check if the tool has an outputSchema - const outputSchema = this._cachedToolOutputSchemas.get(params.name); + const outputSchema = this.getToolOutputSchema(params.name); if (outputSchema) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { @@ -472,6 +471,30 @@ export class Client< return result; } + private cacheToolOutputSchemas(tools: Tool[]) { + this._cachedToolOutputSchemas.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the Zod schema + if (tool.outputSchema) { + try { + const zodSchemaCode = parseSchema(tool.outputSchema as JsonSchema); + // The library returns a string of Zod code, we need to evaluate it + // Using Function constructor to safely evaluate the Zod schema + const createSchema = new Function('z', `return ${zodSchemaCode}`); + const zodSchema = createSchema(z); + this._cachedToolOutputSchemas.set(tool.name, zodSchema); + } catch (error) { + console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); + } + } + } + } + + private getToolOutputSchema(toolName: string): z.ZodTypeAny | undefined { + return this._cachedToolOutputSchemas.get(toolName); + } + async listTools( params?: ListToolsRequest["params"], options?: RequestOptions, @@ -483,26 +506,7 @@ export class Client< ); // Cache the tools and their output schemas for future validation - this._cachedTools.clear(); - this._cachedToolOutputSchemas.clear(); - - for (const tool of result.tools) { - this._cachedTools.set(tool.name, tool); - - // If the tool has an outputSchema, create and cache the Zod schema - if (tool.outputSchema) { - try { - const zodSchemaCode = parseSchema(tool.outputSchema); - // The library returns a string of Zod code, we need to evaluate it - // Using Function constructor to safely evaluate the Zod schema - const createSchema = new Function('z', `return ${zodSchemaCode}`); - const zodSchema = createSchema(z); - this._cachedToolOutputSchemas.set(tool.name, zodSchema); - } catch (error) { - console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); - } - } - } + this.cacheToolOutputSchemas(result.tools); return result; } diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index cb845851..0695bfa1 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -192,7 +192,7 @@ server.tool( } }, required: ["stats"] - }, + }, async ({ data }) => { const mean = data.reduce((a, b) => a + b, 0) / data.length; const sorted = [...data].sort((a, b) => a - b); diff --git a/src/server/mcp-outputschema.test.ts b/src/server/mcp-outputschema.test.ts deleted file mode 100644 index e59bd563..00000000 --- a/src/server/mcp-outputschema.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { McpServer } from './mcp.js'; -import { Client } from '../client/index.js'; -import { InMemoryTransport } from '../inMemory.js'; -import { z } from 'zod'; - -describe('McpServer outputSchema support', () => { - let server: McpServer; - let client: Client; - let serverTransport: InMemoryTransport; - let clientTransport: InMemoryTransport; - - beforeEach(async () => { - server = new McpServer({ name: 'test', version: '1.0' }); - client = new Client({ name: 'test-client', version: '1.0' }); - - [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - }); - - afterEach(async () => { - await client.close(); - await server.close(); - }); - - describe('tool registration with outputSchema', () => { - it('should register a tool with outputSchema', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - }, - required: ['result', 'count'] - }; - - const tool = server.tool( - 'test-tool', - 'A test tool', - { input: z.string() }, - outputSchema, - () => ({ structuredContent: { result: 'test', count: 42 } }) - ); - - expect(tool.outputSchema).toEqual(outputSchema); - - // Connect after registering the tool - await server.connect(serverTransport); - await client.connect(clientTransport); - }); - - it('should include outputSchema in ListToolsResult', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ structuredContent: { result: 'test' } }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.listTools(); - expect(result.tools[0].outputSchema).toEqual(outputSchema); - }); - }); - - describe('tool execution with outputSchema', () => { - it('should return structuredContent and auto-generate content for backward compatibility', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ - structuredContent: { result: 'test', count: 42 } - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); - - expect(result.structuredContent).toEqual({ result: 'test', count: 42 }); - expect(result.content).toEqual([{ - type: 'text', - text: JSON.stringify({ result: 'test', count: 42 }, null, 2) - }]); - }); - - it('should preserve both content and structuredContent if tool provides both', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'structured-tool', - { input: z.string() }, - outputSchema, - () => ({ - structuredContent: { result: 'test' }, - content: [{ type: 'text', text: 'Custom text' }] - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'structured-tool', arguments: { input: 'test' } }); - - expect(result.structuredContent).toEqual({ result: 'test' }); - expect(result.content).toEqual([{ type: 'text', text: 'Custom text' }]); - }); - - it('should throw error if tool with outputSchema returns no structuredContent', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'broken-tool', - { input: z.string() }, - outputSchema, - () => ({ - content: [{ type: 'text', text: 'No structured content' }] - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) - .rejects.toThrow('has outputSchema but returned no structuredContent'); - }); - - it('should throw error if tool without outputSchema returns structuredContent', async () => { - server.tool( - 'broken-tool', - { input: z.string() }, - () => ({ - structuredContent: { result: 'test' } - }) - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Call listTools first (but in this case the tool has no outputSchema) - await client.listTools(); - - await expect(client.callTool({ name: 'broken-tool', arguments: { input: 'test' } })) - .rejects.toThrow('has no outputSchema but returned structuredContent'); - }); - - it('should handle error results properly for tools with outputSchema', async () => { - const outputSchema = { - type: 'object', - properties: { - result: { type: 'string' } - } - }; - - server.tool( - 'error-tool', - { input: z.string() }, - outputSchema, - () => { - throw new Error('Tool error'); - } - ); - - // Now connect - await server.connect(serverTransport); - await client.connect(clientTransport); - - // Need to call listTools first to cache the outputSchema - await client.listTools(); - - const result = await client.callTool({ name: 'error-tool', arguments: { input: 'test' } }); - - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ - type: 'text', - text: 'Tool error' - }]); - expect(result.structuredContent).toBeUndefined(); - }); - }); -}); \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index b9561739..a9a56c61 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -13,12 +13,16 @@ import { CompleteResultSchema, LoggingMessageNotificationSchema, Notification, + TextContent, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; describe("McpServer", () => { + /*** + * Test: Basic Server Instance + */ test("should expose underlying Server instance", () => { const mcpServer = new McpServer({ name: "test server", @@ -28,6 +32,9 @@ describe("McpServer", () => { expect(mcpServer.server).toBeDefined(); }); + /*** + * Test: Notification Sending via Server + */ test("should allow sending notifications via Server", async () => { const mcpServer = new McpServer( { @@ -75,6 +82,9 @@ describe("McpServer", () => { }); describe("ResourceTemplate", () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ test("should create ResourceTemplate with string pattern", () => { const template = new ResourceTemplate("test://{category}/{id}", { list: undefined, @@ -83,6 +93,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ test("should create ResourceTemplate with UriTemplate", () => { const uriTemplate = new UriTemplate("test://{category}/{id}"); const template = new ResourceTemplate(uriTemplate, { list: undefined }); @@ -90,6 +103,9 @@ describe("ResourceTemplate", () => { expect(template.listCallback).toBeUndefined(); }); + /*** + * Test: ResourceTemplate with List Callback + */ test("should create ResourceTemplate with list callback", async () => { const list = jest.fn().mockResolvedValue({ resources: [{ name: "Test", uri: "test://example" }], @@ -111,6 +127,9 @@ describe("ResourceTemplate", () => { }); describe("tool()", () => { + /*** + * Test: Zero-Argument Tool Registration + */ test("should register zero-argument tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -178,6 +197,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Updating Existing Tool + */ test("should update existing tool", async () => { const mcpServer = new McpServer({ name: "test server", @@ -244,6 +266,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Updating Tool with Schema + */ test("should update tool with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -339,6 +364,9 @@ describe("tool()", () => { expect(notifications).toHaveLength(0) }); + /*** + * Test: Tool List Changed Notifications + */ test("should send tool list changed notifications when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -404,6 +432,9 @@ describe("tool()", () => { ]) }); + /*** + * Test: Tool Registration with Parameters + */ test("should register tool with params", async () => { const mcpServer = new McpServer({ name: "test server", @@ -414,6 +445,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool( "test", { @@ -430,6 +462,17 @@ describe("tool()", () => { }), ); + // new api + mcpServer.tool( + "test (new api)", + { + inputSchema: { name: z.string(), value: z.number() }, + callback: async ({ name, value }) => ({ + content: [{ type: "text", text: `${name}: ${value}` }], + }), + } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -445,7 +488,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", @@ -454,8 +497,13 @@ describe("tool()", () => { value: { type: "number" }, }, }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); }); + /*** + * Test: Tool Registration with Description + */ test("should register tool with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -466,6 +514,7 @@ describe("tool()", () => { version: "1.0", }); + // old api mcpServer.tool("test", "Test description", async () => ({ content: [ { @@ -475,6 +524,20 @@ describe("tool()", () => { ], })); + // new api + mcpServer.tool("test (new api)", { + description: "Test description", + callback: async ({}) => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -490,11 +553,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("Test description"); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("Test description"); }); - + + /*** + * Test: Tool Registration with Annotations + */ test("should register tool with annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -514,6 +582,18 @@ describe("tool()", () => { ], })); + mcpServer.tool("test (new api)", { + annotations: { title: "Test Tool", readOnlyHint: true }, + callback: async ({}) => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -529,11 +609,16 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); }); - + + /*** + * Test: Tool Registration with Parameters and Annotations + */ test("should register tool with params and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -553,6 +638,14 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + inputSchema: { name: z.string() }, + annotations: { title: "Test Tool", readOnlyHint: true }, + callback: async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -566,15 +659,21 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toMatchObject({ type: "object", properties: { name: { type: "string" } } }); expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); - + + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ test("should register tool with description, params, and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -595,6 +694,15 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + description: "A tool with everything", + inputSchema: { name: z.string() }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + callback: async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -608,7 +716,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("A tool with everything"); expect(result.tools[0].inputSchema).toMatchObject({ @@ -620,8 +728,15 @@ describe("tool()", () => { readOnlyHint: true, openWorldHint: false }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ test("should register tool with description, empty params, and annotations", async () => { const mcpServer = new McpServer({ name: "test server", @@ -642,6 +757,15 @@ describe("tool()", () => { }) ); + mcpServer.tool("test (new api)", { + description: "A tool with everything but empty params", + inputSchema: {}, + annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + callback: async ({}) => ({ + content: [{ type: "text", text: "Test response" }] + }) + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -655,7 +779,7 @@ describe("tool()", () => { ListToolsResultSchema, ); - expect(result.tools).toHaveLength(1); + expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("A tool with everything but empty params"); expect(result.tools[0].inputSchema).toMatchObject({ @@ -667,8 +791,15 @@ describe("tool()", () => { readOnlyHint: true, openWorldHint: false }); + expect(result.tools[1].name).toBe("test (new api)"); + expect(result.tools[1].description).toBe("A tool with everything but empty params"); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); + /*** + * Test: Tool Argument Validation + */ test("should validate tool args", async () => { const mcpServer = new McpServer({ name: "test server", @@ -703,6 +834,21 @@ describe("tool()", () => { }), ); + mcpServer.tool("test (new api)", { + inputSchema: { + name: z.string(), + value: z.number(), + }, + callback: async ({ name, value }) => ({ + content: [ + { + type: "text", + text: `${name}: ${value}`, + }, + ], + }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -726,8 +872,27 @@ describe("tool()", () => { CallToolResultSchema, ), ).rejects.toThrow(/Invalid arguments/); + + await expect( + client.request( + { + method: "tools/call", + params: { + name: "test (new api)", + arguments: { + name: "test", + value: "not a number", + }, + }, + }, + CallToolResultSchema, + ), + ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Tool Registration + */ test("should prevent duplicate tool registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -755,6 +920,9 @@ describe("tool()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Tool Registration + */ test("should allow registering multiple tools", () => { const mcpServer = new McpServer({ name: "test server", @@ -768,6 +936,9 @@ describe("tool()", () => { mcpServer.tool("tool2", () => ({ content: [] })); }); + /*** + * Test: Tool with Output Schema and Structured Content + */ test("should support tool with outputSchema and structuredContent", async () => { const mcpServer = new McpServer({ name: "test server", @@ -787,35 +958,28 @@ describe("tool()", () => { ); // Register a tool with outputSchema - const registeredTool = mcpServer.tool( + mcpServer.tool( "test", - "Test tool with structured output", { - input: z.string(), + description: "Test tool with structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + callback: async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + }), }, - async ({ input }) => ({ - // When outputSchema is defined, return structuredContent instead of content - structuredContent: { - processedInput: input, - resultType: "structured", - timestamp: "2023-01-01T00:00:00Z" - }, - }), ); - // Update the tool to add outputSchema - registeredTool.update({ - outputSchema: { - type: "object", - properties: { - processedInput: { type: "string" }, - resultType: { type: "string" }, - timestamp: { type: "string", format: "date-time" } - }, - required: ["processedInput", "resultType", "timestamp"] - } - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -833,17 +997,17 @@ describe("tool()", () => { ); expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toEqual({ + expect(listResult.tools[0].outputSchema).toMatchObject({ type: "object", properties: { processedInput: { type: "string" }, resultType: { type: "string" }, - timestamp: { type: "string", format: "date-time" } + timestamp: { type: "string" } }, required: ["processedInput", "resultType", "timestamp"] }); - // Call the tool and verify it returns structuredContent + // Call the tool and verify it returns valid structuredContent const result = await client.request( { method: "tools/call", @@ -858,9 +1022,6 @@ describe("tool()", () => { ); expect(result.structuredContent).toBeDefined(); - // For backward compatibility, content is auto-generated from structuredContent - expect(result.content).toBeDefined(); - const structuredContent = result.structuredContent as { processedInput: string; resultType: string; @@ -869,8 +1030,85 @@ describe("tool()", () => { expect(structuredContent.processedInput).toBe("hello"); expect(structuredContent.resultType).toBe("structured"); expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z"); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: "text" }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test("should fail schema validation when tool returns invalid structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // Register a tool with outputSchema that returns invalid data + mcpServer.tool( + "test", + { + description: "Test tool with invalid structured output", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }, + callback: async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }, + }), + }, + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // First call listTools to cache the outputSchema in the client + await client.listTools(); + + // Call the tool and expect it to throw a validation error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).rejects.toThrow(/Structured content does not match the tool's output schema/); + }); + + /*** + * Test: Pass Session ID to Tool Callback + */ test("should pass sessionId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -924,6 +1162,9 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + /*** + * Test: Pass Request ID to Tool Callback + */ test("should pass requestId to tool callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -977,6 +1218,9 @@ describe("tool()", () => { expect(result.content && result.content[0].text).toContain("Received request ID:"); }); + /*** + * Test: Send Notification within Tool Call + */ test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { @@ -1034,6 +1278,9 @@ describe("tool()", () => { expect(receivedLogMessage).toBe(loggingMessage); }); + /*** + * Test: Client to Server Tool Call + */ test("should allow client to call server tools", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1097,6 +1344,9 @@ describe("tool()", () => { ]); }); + /*** + * Test: Graceful Tool Error Handling + */ test("should handle server tool errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1146,6 +1396,9 @@ describe("tool()", () => { ]); }); + /*** + * Test: McpError for Invalid Tool Name + */ test("should throw McpError for invalid tool name", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1196,6 +1449,9 @@ describe("tool()", () => { }); describe("resource()", () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ test("should register resource with uri and readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1235,6 +1491,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource"); }); + /*** + * Test: Update Resource with URI + */ test("should update resource with uri", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1297,6 +1556,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Update Resource Template + */ test("should update resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1363,6 +1625,9 @@ describe("resource()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Resource List Changed Notification + */ test("should send resource list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1416,6 +1681,9 @@ describe("resource()", () => { ]); }); + /*** + * Test: Remove Resource and Send Notification + */ test("should remove resource and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1477,6 +1745,9 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource2"); }); + /*** + * Test: Remove Resource Template and Send Notification + */ test("should remove resource template and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1541,6 +1812,9 @@ describe("resource()", () => { expect(result2.resourceTemplates).toHaveLength(0); }); + /*** + * Test: Resource Registration with Metadata + */ test("should register resource with metadata", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1588,6 +1862,9 @@ describe("resource()", () => { expect(result.resources[0].mimeType).toBe("text/plain"); }); + /*** + * Test: Resource Template Registration + */ test("should register resource template", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1633,6 +1910,9 @@ describe("resource()", () => { ); }); + /*** + * Test: Resource Template with List Callback + */ test("should register resource template with listCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1691,6 +1971,9 @@ describe("resource()", () => { expect(result.resources[1].uri).toBe("test://resource/2"); }); + /*** + * Test: Template Variables to Read Callback + */ test("should pass template variables to readCallback", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1737,6 +2020,9 @@ describe("resource()", () => { expect(result.contents[0].text).toBe("Category: books, ID: 123"); }); + /*** + * Test: Preventing Duplicate Resource Registration + */ test("should prevent duplicate resource registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1764,6 +2050,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Resource Registration + */ test("should allow registering multiple resources", () => { const mcpServer = new McpServer({ name: "test server", @@ -1791,6 +2080,9 @@ describe("resource()", () => { })); }); + /*** + * Test: Preventing Duplicate Resource Template Registration + */ test("should prevent duplicate resource template registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -1826,6 +2118,9 @@ describe("resource()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Graceful Resource Read Error Handling + */ test("should handle resource read errors gracefully", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1861,6 +2156,9 @@ describe("resource()", () => { ).rejects.toThrow(/Resource read failed/); }); + /*** + * Test: McpError for Invalid Resource URI + */ test("should throw McpError for invalid resource URI", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1901,6 +2199,9 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Resource Template Parameter Completion + */ test("should support completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1966,6 +2267,9 @@ describe("resource()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Resource Template Parameter Completion + */ test("should support filtered completion of resource template parameters", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2034,6 +2338,9 @@ describe("resource()", () => { expect(result.completion.total).toBe(2); }); + /*** + * Test: Pass Request ID to Resource Callback + */ test("should pass requestId to resource callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2089,6 +2396,9 @@ describe("resource()", () => { }); describe("prompt()", () => { + /*** + * Test: Zero-Argument Prompt Registration + */ test("should register zero-argument prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2130,6 +2440,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("test"); expect(result.prompts[0].arguments).toBeUndefined(); }); + /*** + * Test: Updating Existing Prompt + */ test("should update existing prompt", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2198,6 +2511,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Updating Prompt with Schema + */ test("should update prompt with schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2291,6 +2607,9 @@ describe("prompt()", () => { expect(notifications).toHaveLength(0); }); + /*** + * Test: Prompt List Changed Notification + */ test("should send prompt list changed notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2350,6 +2669,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Remove Prompt and Send Notification + */ test("should remove prompt and send notification when connected", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2428,6 +2750,9 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("prompt2"); }); + /*** + * Test: Prompt Registration with Arguments Schema + */ test("should register prompt with args schema", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2480,6 +2805,9 @@ describe("prompt()", () => { ]); }); + /*** + * Test: Prompt Registration with Description + */ test("should register prompt with description", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2522,6 +2850,9 @@ describe("prompt()", () => { expect(result.prompts[0].description).toBe("Test description"); }); + /*** + * Test: Prompt Argument Validation + */ test("should validate prompt args", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2584,6 +2915,9 @@ describe("prompt()", () => { ).rejects.toThrow(/Invalid arguments/); }); + /*** + * Test: Preventing Duplicate Prompt Registration + */ test("should prevent duplicate prompt registration", () => { const mcpServer = new McpServer({ name: "test server", @@ -2617,6 +2951,9 @@ describe("prompt()", () => { }).toThrow(/already registered/); }); + /*** + * Test: Multiple Prompt Registration + */ test("should allow registering multiple prompts", () => { const mcpServer = new McpServer({ name: "test server", @@ -2650,6 +2987,9 @@ describe("prompt()", () => { })); }); + /*** + * Test: Prompt Registration with Arguments + */ test("should allow registering prompts with arguments", () => { const mcpServer = new McpServer({ name: "test server", @@ -2672,6 +3012,9 @@ describe("prompt()", () => { ); }); + /*** + * Test: Resources and Prompts with Completion Handlers + */ test("should allow registering both resources and prompts with completion handlers", () => { const mcpServer = new McpServer({ name: "test server", @@ -2713,6 +3056,9 @@ describe("prompt()", () => { ); }); + /*** + * Test: McpError for Invalid Prompt Name + */ test("should throw McpError for invalid prompt name", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2764,6 +3110,9 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + /*** + * Test: Prompt Argument Completion + */ test("should support completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2829,6 +3178,9 @@ describe("prompt()", () => { expect(result.completion.total).toBe(3); }); + /*** + * Test: Filtered Prompt Argument Completion + */ test("should support filtered completion of prompt arguments", async () => { const mcpServer = new McpServer({ name: "test server", @@ -2896,6 +3248,9 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); + /*** + * Test: Pass Request ID to Prompt Callback + */ test("should pass requestId to prompt callback via RequestHandlerExtra", async () => { const mcpServer = new McpServer({ name: "test server", diff --git a/src/server/mcp.ts b/src/server/mcp.ts index faf8faaf..abb75279 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -90,7 +90,7 @@ export class McpServer { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -116,15 +116,17 @@ export class McpServer { description: tool.description, inputSchema: tool.inputSchema ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - }) as Tool["inputSchema"]) + strictUnions: true, + }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, }; - // Only include outputSchema if it's defined if (tool.outputSchema) { - toolDefinition.outputSchema = tool.outputSchema; + toolDefinition.outputSchema = zodToJsonSchema( + tool.outputSchema, + { strictUnions: true } + ) as Tool["outputSchema"]; } return toolDefinition; @@ -447,7 +449,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -530,7 +532,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -682,7 +684,7 @@ export class McpServer { paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with both parameter schema and annotations. */ @@ -692,7 +694,7 @@ export class McpServer { annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; - + /** * Registers a tool with description, parameter schema, and annotations. */ @@ -701,117 +703,92 @@ export class McpServer { description: string, paramsSchema: Args, annotations: ToolAnnotations, - cb: ToolCallback, + callback: ToolCallback, ): RegisteredTool; /** - * Registers a tool with output schema. + * Registers a tool from a config object. */ - tool( + tool( name: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - cb: ToolCallback, + config: { + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + callback: ToolCallback + } ): RegisteredTool; /** - * Registers a tool with description and output schema. + * tool() implementation. Parses arguments passed to overrides defined above. */ - tool( - name: string, - description: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - cb: ToolCallback, - ): RegisteredTool; - - /** - * Registers a tool with parameter schema, output schema, and annotations. - */ - tool( - name: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - annotations: ToolAnnotations, - cb: ToolCallback, - ): RegisteredTool; - - /** - * Registers a tool with description, parameter schema, output schema, and annotations. - */ - tool( - name: string, - description: string, - paramsSchema: Args, - outputSchema: Tool["outputSchema"], - annotations: ToolAnnotations, - cb: ToolCallback, - ): RegisteredTool; - tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } let description: string | undefined; - if (typeof rest[0] === "string") { - description = rest.shift() as string; - } - - let paramsSchema: ZodRawShape | undefined; - let outputSchema: Tool["outputSchema"] | undefined; + let inputSchema: ZodRawShape | undefined; + let outputSchema: ZodRawShape | undefined; let annotations: ToolAnnotations | undefined; - - // Handle the different overload combinations - if (rest.length > 1) { - // We have at least two more args before the callback - const firstArg = rest[0]; - - if (isZodRawShape(firstArg)) { - // We have a params schema as the first arg - paramsSchema = rest.shift() as ZodRawShape; - - // Check if the next arg is potentially annotations or outputSchema - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null) { - const nextArg = rest[0]; + let callback: ToolCallback; - // Check if it's a JSON Schema (outputSchema) - if (typeof nextArg === "object" && "type" in nextArg) { - outputSchema = rest.shift() as Tool["outputSchema"]; + if (rest.length == 1 && typeof rest[0] === "object" && rest[0] !== null && 'callback' in rest[0]) { + // New style: tool(name, { description, inputSchema, outputSchema, annotations, callback }) + // Future additions to tool definition should be added to config object. - // Check if there's still an annotations object - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { - annotations = rest.shift() as ToolAnnotations; - } - } else if (!(isZodRawShape(nextArg))) { - // It's annotations - annotations = rest.shift() as ToolAnnotations; - } - } - } else if (typeof firstArg === "object" && firstArg !== null) { - // Check if it's a JSON Schema (outputSchema) - if ("type" in firstArg) { - outputSchema = rest.shift() as Tool["outputSchema"]; - - // Check if there's still an annotations object + const config = rest[0] as { + description?: string; + inputSchema?: ZodRawShape; + outputSchema?: ZodRawShape; + annotations?: ToolAnnotations; + callback: ToolCallback; + }; + ({ description, inputSchema, outputSchema, annotations, callback } = config) ; + + } else { + // Old style: tool properties are passed as separate arguments, with omissions allowed. + // Support for this style is frozen as of protocol version 2025-03-26. Future additions + // to tool definition should *NOT* be added. + + if (typeof rest[0] === "string") { + description = rest.shift() as string; + } + + // Handle the different overload combinations + if (rest.length > 1) { + // We have at least one more arg before the callback + const firstArg = rest[0]; + + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + inputSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) annotations = rest.shift() as ToolAnnotations; } - } else { - // It's annotations + } else if (typeof firstArg === "object" && firstArg !== null) { + // Not a ZodRawShape, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) annotations = rest.shift() as ToolAnnotations; } } + callback = rest[0] as ToolCallback; } - const cb = rest[0] as ToolCallback; const registeredTool: RegisteredTool = { description, inputSchema: - paramsSchema === undefined ? undefined : z.object(paramsSchema), - outputSchema, + inputSchema === undefined ? undefined : z.object(inputSchema), + outputSchema: + outputSchema === undefined ? undefined : z.object(outputSchema), annotations, - callback: cb, + callback: callback, enabled: true, disable: () => registeredTool.update({ enabled: false }), enable: () => registeredTool.update({ enabled: true }), @@ -823,7 +800,6 @@ export class McpServer { } if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) - if (typeof updates.outputSchema !== "undefined") registeredTool.outputSchema = updates.outputSchema if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled @@ -1019,22 +995,31 @@ export class ResourceTemplate { */ export type ToolCallback = Args extends ZodRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; + ? ( + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => CallToolResult | Promise + : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; - outputSchema?: Tool["outputSchema"]; + outputSchema?: AnyZodObject; annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, outputSchema?: Tool["outputSchema"], callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void + update( + updates: { + name?: string | null, + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1107,23 +1092,23 @@ export type RegisteredResourceTemplate = { enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; type PromptArgsRawShape = { [k: string]: - | ZodType - | ZodOptional>; + | ZodType + | ZodOptional>; }; export type PromptCallback< Args extends undefined | PromptArgsRawShape = undefined, > = Args extends PromptArgsRawShape ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise + args: z.objectOutputType, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { diff --git a/src/types.ts b/src/types.ts index dc9ab5df..6b5a696a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -838,7 +838,14 @@ export const ToolSchema = z * If set, a CallToolResult for this Tool MUST contain a structuredContent field whose contents validate against this schema. * If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field. */ - outputSchema: z.optional(z.object({}).passthrough()), + outputSchema: z.optional( + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), /** * Optional additional tool information. */ From e1abdab7f51a922446db48914bb08049381a7a92 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 13 May 2025 16:32:22 -0400 Subject: [PATCH 033/194] update examples, update package-lock.json --- package-lock.json | 10 + .../client/parallelToolCallsClient.ts | 20 +- src/examples/client/simpleStreamableHttp.ts | 20 +- .../streamableHttpWithSseFallbackClient.ts | 20 +- src/examples/server/lowLevelOutputSchema.ts | 116 ++++++++++ src/examples/server/mcpServerOutputSchema.ts | 201 ++--------------- src/examples/server/outputSchema.ts | 209 ------------------ .../server/testOutputSchemaServers.ts | 93 ++++++++ 8 files changed, 258 insertions(+), 431 deletions(-) create mode 100644 src/examples/server/lowLevelOutputSchema.ts delete mode 100644 src/examples/server/outputSchema.ts create mode 100755 src/examples/server/testOutputSchemaServers.ts diff --git a/package-lock.json b/package-lock.json index ed986a69..425abd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -4879,6 +4880,15 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema-to-zod": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", + "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", + "license": "ISC", + "bin": { + "json-schema-to-zod": "dist/cjs/cli.js" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 17a77872..83c101b7 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,19 +61,13 @@ async function main(): Promise { // Log the results from each tool call for (const [caller, result] of Object.entries(toolResults)) { console.log(`\n=== Tool result for ${caller} ===`); - if (result.content) { - result.content.forEach((item: { type: string; text?: string; }) => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); - } else if (result.structuredContent) { - console.log(` Structured content: ${result.structuredContent}`); - } else { - console.log(` No content returned`); - } + result.content?.forEach((item: { type: string; text?: string; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } // 3. Wait for all notifications (10 seconds) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 2debacf6..9a20f03a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -341,19 +341,13 @@ async function callTool(name: string, args: Record): Promise { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); - } else if (result.structuredContent) { - console.log(` Structured content: ${result.structuredContent}`); - } else { - console.log(' No content returned'); - } + result.content?.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 06f35004..aaefc680 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,19 +173,13 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - if (result.content) { - result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); - } else if (result.structuredContent) { - console.log(` Structured content: ${result.structuredContent}`); - } else { - console.log(' No content returned'); - } + result.content?.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); } catch (error) { console.log(`Error calling notification tool: ${error}`); } diff --git a/src/examples/server/lowLevelOutputSchema.ts b/src/examples/server/lowLevelOutputSchema.ts new file mode 100644 index 00000000..dfb28415 --- /dev/null +++ b/src/examples/server/lowLevelOutputSchema.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Example MCP server demonstrating tool outputSchema support using the low-level Server API + * This server manually handles tool listing and invocation requests to return structured data + * For a simpler high-level API approach, see mcpServerOutputSchema.ts + */ + +import { Server } from "../../server/index.js"; +import { StdioServerTransport } from "../../server/stdio.js"; +import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; + +const server = new Server( + { + name: "output-schema-low-level-example", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Tool with structured output +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "get_weather", + description: "Get weather information for a city", + inputSchema: { + type: "object", + properties: { + city: { type: "string", description: "City name" }, + country: { type: "string", description: "Country code (e.g., US, UK)" } + }, + required: ["city", "country"] + }, + outputSchema: { + type: "object", + properties: { + temperature: { + type: "object", + properties: { + celsius: { type: "number" }, + fahrenheit: { type: "number" } + }, + required: ["celsius", "fahrenheit"] + }, + conditions: { + type: "string", + enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] + }, + humidity: { type: "number", minimum: 0, maximum: 100 }, + wind: { + type: "object", + properties: { + speed_kmh: { type: "number" }, + direction: { type: "string" } + }, + required: ["speed_kmh", "direction"] + } + }, + required: ["temperature", "conditions", "humidity", "wind"] + } + } + ] +})); + +server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + switch (request.params.name) { + case "get_weather": { + const { city, country } = request.params.arguments as { city: string; country: string }; + + // Parameters are available but not used in this example + void city; + 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)]; + + // Return structured content matching the outputSchema + return { + structuredContent: { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] + } + } + }; + } + + default: + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } +}); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Low-level Output Schema Example Server running on stdio"); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 0695bfa1..07cb7f96 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -10,47 +10,31 @@ import { z } from "zod"; const server = new McpServer( { - name: "mcp-output-schema-example", + name: "mcp-output-schema-high-level-example", version: "1.0.0", } ); // Define a tool with structured output - Weather data -server.tool( - "get_weather", - "Get weather information for a city", - { +server.tool("get_weather", { + description: "Get weather information for a city", + inputSchema:{ city: z.string().describe("City name"), country: z.string().describe("Country code (e.g., US, UK)") }, - { - type: "object", - properties: { - temperature: { - type: "object", - properties: { - celsius: { type: "number" }, - fahrenheit: { type: "number" } - }, - required: ["celsius", "fahrenheit"] - }, - conditions: { - type: "string", - enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] - }, - humidity: { type: "number", minimum: 0, maximum: 100 }, - wind: { - type: "object", - properties: { - speed_kmh: { type: "number" }, - direction: { type: "string" } - }, - required: ["speed_kmh", "direction"] - } - }, - required: ["temperature", "conditions", "humidity", "wind"] + outputSchema: { + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) }, - async ({ city, country }: { city: string; country: string }) => { + callback: async ({ city, country }) => { // Parameters are available but not used in this example void city; void country; @@ -73,161 +57,12 @@ server.tool( } }; } -); - -// Define a tool for data processing with structured output -server.tool( - "process_csv", - "Process CSV data and return statistics", - { - csv_data: z.string().describe("CSV data as a string"), - delimiter: z.string().default(",").describe("CSV delimiter") - }, - { - type: "object", - properties: { - row_count: { type: "integer" }, - column_count: { type: "integer" }, - headers: { - type: "array", - items: { type: "string" } - }, - data_types: { - type: "object", - additionalProperties: { - type: "string", - enum: ["number", "string", "date", "boolean"] - } - }, - summary: { - type: "object", - additionalProperties: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - mean: { type: "number" }, - count: { type: "integer" } - } - } - } - }, - required: ["row_count", "column_count", "headers", "data_types"] - }, - async ({ csv_data, delimiter }) => { - const lines = csv_data.trim().split('\n'); - const headers = lines[0].split(delimiter).map(h => h.trim()); - const data = lines.slice(1).map(line => line.split(delimiter).map(cell => cell.trim())); - - // Infer data types - const dataTypes: { [key: string]: string } = {}; - const summary: { [key: string]: unknown } = {}; - - headers.forEach((header, idx) => { - const values = data.map(row => row[idx]); - const numericValues = values.filter(v => !isNaN(Number(v)) && v !== ''); - - if (numericValues.length === values.length) { - dataTypes[header] = "number"; - const numbers = numericValues.map(Number); - summary[header] = { - min: Math.min(...numbers), - max: Math.max(...numbers), - mean: numbers.reduce((a, b) => a + b, 0) / numbers.length, - count: numbers.length - }; - } else { - dataTypes[header] = "string"; - } - }); - - return { - structuredContent: { - row_count: data.length, - column_count: headers.length, - headers, - data_types: dataTypes, - summary - } - }; - } -); - -// Traditional tool without outputSchema for comparison -server.tool( - "echo", - "Echo back the input message", - { - message: z.string() - }, - async ({ message }) => { - return { - content: [ - { - type: "text", - text: `Echo: ${message}` - } - ] - }; - } -); - -// Tool that can return both structured and unstructured content -server.tool( - "hybrid_tool", - "Tool that returns both structured and readable content", - { - data: z.array(z.number()).describe("Array of numbers to analyze") - }, - { - type: "object", - properties: { - stats: { - type: "object", - properties: { - mean: { type: "number" }, - median: { type: "number" }, - std_dev: { type: "number" } - } - } - }, - required: ["stats"] - }, - async ({ data }) => { - const mean = data.reduce((a, b) => a + b, 0) / data.length; - const sorted = [...data].sort((a, b) => a - b); - const median = sorted.length % 2 === 0 - ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 - : sorted[Math.floor(sorted.length / 2)]; - const variance = data.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / data.length; - const std_dev = Math.sqrt(variance); - - return { - structuredContent: { - stats: { - mean: Math.round(mean * 100) / 100, - median: Math.round(median * 100) / 100, - std_dev: Math.round(std_dev * 100) / 100 - } - }, - // Also provide human-readable content for backward compatibility - content: [ - { - type: "text", - text: `Analysis of ${data.length} numbers: -- Mean: ${Math.round(mean * 100) / 100} -- Median: ${Math.round(median * 100) / 100} -- Standard Deviation: ${Math.round(std_dev * 100) / 100}` - } - ] - }; - } -); +}); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); - console.error("McpServer Output Schema Example running on stdio"); + console.error("High-level Output Schema Example Server running on stdio"); } main().catch((error) => { diff --git a/src/examples/server/outputSchema.ts b/src/examples/server/outputSchema.ts deleted file mode 100644 index 8530a760..00000000 --- a/src/examples/server/outputSchema.ts +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env node -/** - * Example MCP server demonstrating tool outputSchema support - * This server exposes tools that return structured data - */ - -import { Server } from "../../server/index.js"; -import { StdioServerTransport } from "../../server/stdio.js"; -import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; - -const server = new Server( - { - name: "output-schema-example", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } -); - -// Tool with structured output -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "calculate_bmi", - description: "Calculate BMI given height and weight", - inputSchema: { - type: "object", - properties: { - height_cm: { type: "number", description: "Height in centimeters" }, - weight_kg: { type: "number", description: "Weight in kilograms" } - }, - required: ["height_cm", "weight_kg"] - }, - outputSchema: { - type: "object", - properties: { - bmi: { type: "number", description: "Body Mass Index" }, - category: { - type: "string", - enum: ["underweight", "normal", "overweight", "obese"], - description: "BMI category" - }, - healthy_weight_range: { - type: "object", - properties: { - min_kg: { type: "number" }, - max_kg: { type: "number" } - }, - required: ["min_kg", "max_kg"] - } - }, - required: ["bmi", "category", "healthy_weight_range"] - } - }, - { - name: "analyze_text", - description: "Analyze text and return structured insights", - inputSchema: { - type: "object", - properties: { - text: { type: "string", description: "Text to analyze" } - }, - required: ["text"] - }, - outputSchema: { - type: "object", - properties: { - word_count: { type: "integer" }, - sentence_count: { type: "integer" }, - character_count: { type: "integer" }, - reading_time_minutes: { type: "number" }, - sentiment: { - type: "string", - enum: ["positive", "negative", "neutral"] - }, - key_phrases: { - type: "array", - items: { type: "string" } - } - }, - required: ["word_count", "sentence_count", "character_count", "reading_time_minutes"] - } - }, - { - name: "traditional_tool", - description: "A traditional tool without outputSchema", - inputSchema: { - type: "object", - properties: { - message: { type: "string" } - }, - required: ["message"] - } - } - ] -})); - -server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { - switch (request.params.name) { - case "calculate_bmi": { - const { height_cm, weight_kg } = request.params.arguments as { height_cm: number; weight_kg: number }; - - const height_m = height_cm / 100; - const bmi = weight_kg / (height_m * height_m); - - let category: string; - if (bmi < 18.5) category = "underweight"; - else if (bmi < 25) category = "normal"; - else if (bmi < 30) category = "overweight"; - else category = "obese"; - - // Calculate healthy weight range for normal BMI (18.5-24.9) - const min_healthy_bmi = 18.5; - const max_healthy_bmi = 24.9; - const min_healthy_weight = min_healthy_bmi * height_m * height_m; - const max_healthy_weight = max_healthy_bmi * height_m * height_m; - - // Return structured content matching the outputSchema - return { - structuredContent: { - bmi: Math.round(bmi * 10) / 10, - category, - healthy_weight_range: { - min_kg: Math.round(min_healthy_weight * 10) / 10, - max_kg: Math.round(max_healthy_weight * 10) / 10 - } - } - }; - } - - case "analyze_text": { - const { text } = request.params.arguments as { text: string }; - - // Simple text analysis - const words = text.trim().split(/\s+/); - const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); - const wordsPerMinute = 200; // Average reading speed - - // Very simple sentiment analysis (for demo purposes) - const positiveWords = ["good", "great", "excellent", "happy", "positive", "amazing"]; - const negativeWords = ["bad", "poor", "terrible", "sad", "negative", "awful"]; - - let positiveCount = 0; - let negativeCount = 0; - words.forEach(word => { - if (positiveWords.includes(word.toLowerCase())) positiveCount++; - if (negativeWords.includes(word.toLowerCase())) negativeCount++; - }); - - let sentiment: string; - if (positiveCount > negativeCount) sentiment = "positive"; - else if (negativeCount > positiveCount) sentiment = "negative"; - else sentiment = "neutral"; - - // Extract key phrases (simple approach - just common bigrams) - const keyPhrases: string[] = []; - for (let i = 0; i < words.length - 1; i++) { - if (words[i].length > 3 && words[i + 1].length > 3) { - keyPhrases.push(`${words[i]} ${words[i + 1]}`); - } - } - - return { - structuredContent: { - word_count: words.length, - sentence_count: sentences.length, - character_count: text.length, - reading_time_minutes: Math.round((words.length / wordsPerMinute) * 10) / 10, - sentiment, - key_phrases: keyPhrases.slice(0, 5) // Top 5 phrases - } - }; - } - - case "traditional_tool": { - const { message } = request.params.arguments as { message: string }; - - // Traditional tool returns content array - return { - content: [ - { - type: "text", - text: `Processed message: ${message.toUpperCase()}` - } - ] - }; - } - - default: - throw new McpError( - ErrorCode.MethodNotFound, - `Unknown tool: ${request.params.name}` - ); - } -}); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Output Schema Example Server running on stdio"); -} - -main().catch((error) => { - console.error("Server error:", error); - process.exit(1); -}); \ No newline at end of file diff --git a/src/examples/server/testOutputSchemaServers.ts b/src/examples/server/testOutputSchemaServers.ts new file mode 100755 index 00000000..f6966b5e --- /dev/null +++ b/src/examples/server/testOutputSchemaServers.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Client to test outputSchema servers + * This client connects to either the high-level or low-level outputSchema server + * and calls the get_weather tool to demonstrate structured output + */ + +import { Client } from "../../client/index.js"; +import { StdioClientTransport } from "../../client/stdio.js"; +import { ListToolsResultSchema, CallToolResultSchema } from "../../types.js"; + +async function main() { + const serverPath = process.argv[2]; + + if (!serverPath) { + console.error("Usage: npx tsx testOutputSchemaServers.ts "); + console.error("Example: npx tsx testOutputSchemaServers.ts ./mcpServerOutputSchema.ts"); + process.exit(1); + } + + console.log(`Connecting to ${serverPath}...`); + + // Create transport that spawns the server process + const transport = new StdioClientTransport({ + command: "npx", + args: ["tsx", serverPath] + }); + + const client = new Client({ + name: "output-schema-test-client", + version: "1.0.0" + }, { + capabilities: {} + }); + + try { + await client.connect(transport); + console.log("Connected to server\n"); + + // List available tools + console.log("Listing available tools..."); + const toolsResult = await client.request({ + method: "tools/list" + }, ListToolsResultSchema); + + console.log("Available tools:"); + for (const tool of toolsResult.tools) { + console.log(`- ${tool.name}: ${tool.description}`); + if (tool.outputSchema) { + console.log(" Has outputSchema: Yes"); + console.log(" Output schema:", JSON.stringify(tool.outputSchema, null, 2)); + } else { + console.log(" Has outputSchema: No"); + } + } + + // Call the weather tool + console.log("\nCalling get_weather tool..."); + const weatherResult = await client.request({ + method: "tools/call", + params: { + name: "get_weather", + arguments: { + city: "London", + country: "UK" + } + } + }, CallToolResultSchema); + + console.log("\nWeather tool result:"); + if (weatherResult.structuredContent) { + console.log("Structured content:"); + console.log(JSON.stringify(weatherResult.structuredContent, null, 2)); + } + + if (weatherResult.content) { + console.log("Unstructured content:"); + weatherResult.content.forEach(content => { + if (content.type === "text") { + console.log(content.text); + } + }); + } + + await client.close(); + console.log("\nDisconnected from server"); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } +} + +main(); \ No newline at end of file From 0fa32775500ccf15ede2472ee63d9b28681da953 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 13 May 2025 17:03:43 -0400 Subject: [PATCH 034/194] avoid ({}) => ... in tests --- src/server/mcp.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index a9a56c61..162953af 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -526,11 +526,11 @@ describe("tool()", () => { // new api mcpServer.tool("test (new api)", { - description: "Test description", - callback: async ({}) => ({ + description: "Test description", + callback: async () => ({ content: [ { - type: "text", + type: "text" as const, text: "Test response", }, ], @@ -583,11 +583,11 @@ describe("tool()", () => { })); mcpServer.tool("test (new api)", { - annotations: { title: "Test Tool", readOnlyHint: true }, - callback: async ({}) => ({ + annotations: { title: "Test Tool", readOnlyHint: true }, + callback: async () => ({ content: [ { - type: "text", + type: "text" as const, text: "Test response", }, ], @@ -761,8 +761,8 @@ describe("tool()", () => { description: "A tool with everything but empty params", inputSchema: {}, annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, - callback: async ({}) => ({ - content: [{ type: "text", text: "Test response" }] + callback: async () => ({ + content: [{ type: "text" as const, text: "Test response" }] }) }); From 18c7e795a028603c6b8d6a679a58295f2173aa4f Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 14 May 2025 10:52:20 -0400 Subject: [PATCH 035/194] address comments: 1. validate schemas w/o codegen, 2. relax no-structured-output restriction --- package-lock.json | 113 ++++++++++++++++++++++++++++++--------- package.json | 2 +- src/client/index.test.ts | 67 ----------------------- src/client/index.ts | 43 ++++++--------- 4 files changed, 106 insertions(+), 119 deletions(-) diff --git a/package-lock.json b/package-lock.json index 425abd47..59b21bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "1.11.3", "license": "MIT", "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -1036,6 +1036,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", @@ -2199,15 +2223,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3217,6 +3241,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -3451,8 +3499,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -3501,6 +3548,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4880,20 +4943,11 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/json-schema-to-zod": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/json-schema-to-zod/-/json-schema-to-zod-2.6.1.tgz", - "integrity": "sha512-uiHmWH21h9FjKJkRBntfVGTLpYlCZ1n98D0izIlByqQLqpmkQpNTBtfbdP04Na6+43lgsvrShFh2uWLkQDKJuQ==", - "license": "ISC", - "bin": { - "json-schema-to-zod": "dist/cjs/cli.js" - } - }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5568,6 +5622,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5672,6 +5727,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6455,6 +6519,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 7c4701ae..c23610b8 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,13 @@ "client": "tsx src/cli.ts client" }, "dependencies": { + "ajv": "^8.17.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "json-schema-to-zod": "^2.6.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index d377af86..bbfa80fa 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1261,72 +1261,5 @@ describe('outputSchema validation', () => { ); }); - /*** - * Test: Throw Error when Tool Without outputSchema Returns structuredContent - */ - test('should throw error when tool without outputSchema returns structuredContent', async () => { - const server = new Server({ - name: 'test-server', - version: '1.0.0', - }, { - capabilities: { - tools: {}, - }, - }); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async (request) => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0', - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {}, - }, - // No outputSchema defined - }, - ], - })); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'test-tool') { - // Incorrectly return structuredContent for a tool without outputSchema - return { - structuredContent: { result: 'This should not be allowed' }, - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0', - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - server.connect(serverTransport), - ]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should throw validation error - await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( - /Tool without outputSchema cannot return structuredContent/ - ); - }); }); diff --git a/src/client/index.ts b/src/client/index.ts index c4aed3a4..70870633 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -43,8 +43,7 @@ import { ErrorCode, McpError, } from "../types.js"; -import { z } from "zod"; -import { JsonSchema, parseSchema } from "json-schema-to-zod"; +import { Ajv, type ValidateFunction } from "ajv"; export type ClientOptions = ProtocolOptions & { /** @@ -91,7 +90,8 @@ export class Client< private _serverVersion?: Implementation; private _capabilities: ClientCapabilities; private _instructions?: string; - private _cachedToolOutputSchemas: Map = new Map(); + private _cachedToolOutputValidators: Map = new Map(); + private _ajv: InstanceType; /** * Initializes this client with the given name and version information. @@ -102,6 +102,7 @@ export class Client< ) { super(options); this._capabilities = options?.capabilities ?? {}; + this._ajv = new Ajv({ strict: false, validateFormats: true }); } /** @@ -426,8 +427,8 @@ export class Client< ); // Check if the tool has an outputSchema - const outputSchema = this.getToolOutputSchema(params.name); - if (outputSchema) { + const validator = this.getToolOutputValidator(params.name); + if (validator) { // If tool has outputSchema, it MUST return structuredContent (unless it's an error) if (!result.structuredContent && !result.isError) { throw new McpError( @@ -440,12 +441,12 @@ export class Client< if (result.structuredContent) { try { // Validate the structured content (which is already an object) against the schema - const validationResult = outputSchema.safeParse(result.structuredContent); + const isValid = validator(result.structuredContent); - if (!validationResult.success) { + if (!isValid) { throw new McpError( ErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.error.message}` + `Structured content does not match the tool's output schema: ${this._ajv.errorsText(validator.errors)}` ); } } catch (error) { @@ -458,41 +459,29 @@ export class Client< ); } } - } else { - // If tool doesn't have outputSchema, it MUST NOT return structuredContent - if (result.structuredContent) { - throw new McpError( - ErrorCode.InvalidRequest, - `Tool without outputSchema cannot return structuredContent` - ); - } } return result; } private cacheToolOutputSchemas(tools: Tool[]) { - this._cachedToolOutputSchemas.clear(); + this._cachedToolOutputValidators.clear(); for (const tool of tools) { - // If the tool has an outputSchema, create and cache the Zod schema + // If the tool has an outputSchema, create and cache the Ajv validator if (tool.outputSchema) { try { - const zodSchemaCode = parseSchema(tool.outputSchema as JsonSchema); - // The library returns a string of Zod code, we need to evaluate it - // Using Function constructor to safely evaluate the Zod schema - const createSchema = new Function('z', `return ${zodSchemaCode}`); - const zodSchema = createSchema(z); - this._cachedToolOutputSchemas.set(tool.name, zodSchema); + const validator = this._ajv.compile(tool.outputSchema); + this._cachedToolOutputValidators.set(tool.name, validator); } catch (error) { - console.warn(`Failed to create Zod schema for tool ${tool.name}: ${error}`); + console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`); } } } } - private getToolOutputSchema(toolName: string): z.ZodTypeAny | undefined { - return this._cachedToolOutputSchemas.get(toolName); + private getToolOutputValidator(toolName: string): ValidateFunction | undefined { + return this._cachedToolOutputValidators.get(toolName); } async listTools( From eb0407ea409c3e3babfaf1de0c0b326bab6fa6e8 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 14 May 2025 14:35:13 -0400 Subject: [PATCH 036/194] split comment for readability --- src/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 6b5a696a..2e31a6b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -921,7 +921,10 @@ export const CallToolStructuredResultSchema = ResultSchema.extend({ * A list of content objects that represent the result of the tool call. * * If the Tool defines an outputSchema, this field MAY be present in the result. - * Tools may use this field to provide compatibility with older clients that do not support structured content. + * + * Tools may use this field to provide compatibility with older clients that + * do not support structured content. + * * Clients that support structured content should ignore this field. */ content: z.optional(ContentListSchema), From 712b04e96d44d160b045622149802742ccaf9b74 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 14 May 2025 17:07:50 -0400 Subject: [PATCH 037/194] Refactor McpServer tool registration API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove tool() overload that takes a config object with callback - Add new registerTool() method that separates config from callback - Extract common tool registration logic into _createRegisteredTool() helper - Update all tests to use registerTool() for tools with output schemas - Update examples to use registerTool() and high-level Client API - Add comment noting that output schema validation only works with high-level API This change improves API clarity by using a distinct method for tools with output schemas while maintaining backward compatibility. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/examples/server/mcpServerOutputSchema.ts | 41 ++-- .../server/testOutputSchemaServers.ts | 22 +- src/server/mcp.test.ts | 132 ++++++------ src/server/mcp.ts | 193 +++++++++--------- 4 files changed, 204 insertions(+), 184 deletions(-) diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 07cb7f96..65230fc7 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -16,25 +16,28 @@ const server = new McpServer( ); // Define a tool with structured output - Weather data -server.tool("get_weather", { - description: "Get weather information for a city", - inputSchema:{ - city: z.string().describe("City name"), - country: z.string().describe("Country code (e.g., US, UK)") +server.registerTool( + "get_weather", + { + description: "Get weather information for a city", + inputSchema:{ + city: z.string().describe("City name"), + country: z.string().describe("Country code (e.g., US, UK)") + }, + outputSchema: { + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) + }, }, - outputSchema: { - temperature: z.object({ - celsius: z.number(), - fahrenheit: z.number() - }), - conditions: z.enum(["sunny", "cloudy", "rainy", "stormy", "snowy"]), - humidity: z.number().min(0).max(100), - wind: z.object({ - speed_kmh: z.number(), - direction: z.string() - }) - }, - callback: async ({ city, country }) => { + async ({ city, country }) => { // Parameters are available but not used in this example void city; void country; @@ -57,7 +60,7 @@ server.tool("get_weather", { } }; } -}); +); async function main() { const transport = new StdioServerTransport(); diff --git a/src/examples/server/testOutputSchemaServers.ts b/src/examples/server/testOutputSchemaServers.ts index f6966b5e..911e23c1 100755 --- a/src/examples/server/testOutputSchemaServers.ts +++ b/src/examples/server/testOutputSchemaServers.ts @@ -7,7 +7,6 @@ import { Client } from "../../client/index.js"; import { StdioClientTransport } from "../../client/stdio.js"; -import { ListToolsResultSchema, CallToolResultSchema } from "../../types.js"; async function main() { const serverPath = process.argv[2]; @@ -39,9 +38,7 @@ async function main() { // List available tools console.log("Listing available tools..."); - const toolsResult = await client.request({ - method: "tools/list" - }, ListToolsResultSchema); + const toolsResult = await client.listTools(); console.log("Available tools:"); for (const tool of toolsResult.tools) { @@ -56,16 +53,15 @@ async function main() { // Call the weather tool console.log("\nCalling get_weather tool..."); - const weatherResult = await client.request({ - method: "tools/call", - params: { - name: "get_weather", - arguments: { - city: "London", - country: "UK" - } + // Note: Output schema validation only works when using the high-level Client API + // methods like callTool(). Low-level protocol requests do not validate the response. + const weatherResult = await client.callTool({ + name: "get_weather", + arguments: { + city: "London", + country: "UK" } - }, CallToolResultSchema); + }); console.log("\nWeather tool result:"); if (weatherResult.structuredContent) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 162953af..73103208 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -463,14 +463,14 @@ describe("tool()", () => { ); // new api - mcpServer.tool( + mcpServer.registerTool( "test (new api)", - { - inputSchema: { name: z.string(), value: z.number() }, - callback: async ({ name, value }) => ({ - content: [{ type: "text", text: `${name}: ${value}` }], - }), - } + { + inputSchema: { name: z.string(), value: z.number() }, + }, + async ({ name, value }) => ({ + content: [{ type: "text", text: `${name}: ${value}` }], + }) ); const [clientTransport, serverTransport] = @@ -525,9 +525,12 @@ describe("tool()", () => { })); // new api - mcpServer.tool("test (new api)", { - description: "Test description", - callback: async () => ({ + mcpServer.registerTool( + "test (new api)", + { + description: "Test description", + }, + async () => ({ content: [ { type: "text" as const, @@ -535,7 +538,7 @@ describe("tool()", () => { }, ], }) - }); + ); const [clientTransport, serverTransport] = @@ -582,17 +585,20 @@ describe("tool()", () => { ], })); - mcpServer.tool("test (new api)", { - annotations: { title: "Test Tool", readOnlyHint: true }, - callback: async () => ({ + mcpServer.registerTool( + "test (new api)", + { + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async () => ({ content: [ { type: "text" as const, text: "Test response", }, ], - }), - }); + }) + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -638,13 +644,16 @@ describe("tool()", () => { }) ); - mcpServer.tool("test (new api)", { - inputSchema: { name: z.string() }, - annotations: { title: "Test Tool", readOnlyHint: true }, - callback: async ({ name }) => ({ + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { name: z.string() }, + annotations: { title: "Test Tool", readOnlyHint: true }, + }, + async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }) - }); + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -694,14 +703,17 @@ describe("tool()", () => { }) ); - mcpServer.tool("test (new api)", { - description: "A tool with everything", - inputSchema: { name: z.string() }, - annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, - callback: async ({ name }) => ({ + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything", + inputSchema: { name: z.string() }, + annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + }, + async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }) - }); + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -757,14 +769,17 @@ describe("tool()", () => { }) ); - mcpServer.tool("test (new api)", { - description: "A tool with everything but empty params", - inputSchema: {}, - annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, - callback: async () => ({ + mcpServer.registerTool( + "test (new api)", + { + description: "A tool with everything but empty params", + inputSchema: {}, + annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, + }, + async () => ({ content: [{ type: "text" as const, text: "Test response" }] }) - }); + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -834,20 +849,23 @@ describe("tool()", () => { }), ); - mcpServer.tool("test (new api)", { - inputSchema: { - name: z.string(), - value: z.number(), + mcpServer.registerTool( + "test (new api)", + { + inputSchema: { + name: z.string(), + value: z.number(), + }, }, - callback: async ({ name, value }) => ({ + async ({ name, value }) => ({ content: [ { type: "text", text: `${name}: ${value}`, }, ], - }), - }); + }) + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -958,7 +976,7 @@ describe("tool()", () => { ); // Register a tool with outputSchema - mcpServer.tool( + mcpServer.registerTool( "test", { description: "Test tool with structured output", @@ -970,14 +988,14 @@ describe("tool()", () => { resultType: z.string(), timestamp: z.string() }, - callback: async ({ input }) => ({ - structuredContent: { - processedInput: input, - resultType: "structured", - timestamp: "2023-01-01T00:00:00Z" - }, - }), }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }, + }) ); const [clientTransport, serverTransport] = @@ -1061,7 +1079,7 @@ describe("tool()", () => { ); // Register a tool with outputSchema that returns invalid data - mcpServer.tool( + mcpServer.registerTool( "test", { description: "Test tool with invalid structured output", @@ -1073,15 +1091,15 @@ describe("tool()", () => { resultType: z.string(), timestamp: z.string() }, - callback: async ({ input }) => ({ - structuredContent: { - processedInput: input, - resultType: "structured", - // Missing required 'timestamp' field - someExtraField: "unexpected" // Extra field not in schema - }, - }), }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }, + }) ); const [clientTransport, serverTransport] = diff --git a/src/server/mcp.ts b/src/server/mcp.ts index abb75279..e0de538a 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -219,14 +219,6 @@ export class McpServer { ]; } } else { - // Tool has no outputSchema - if (result.structuredContent) { - throw new McpError( - ErrorCode.InternalError, - `Tool ${request.params.name} has no outputSchema but returned structuredContent`, - ); - } - // Tool must have content if no outputSchema if (!result.content && !result.isError) { throw new McpError( @@ -647,6 +639,47 @@ export class McpServer { } } + private _createRegisteredTool( + name: string, + description: string | undefined, + inputSchema: ZodRawShape | undefined, + outputSchema: ZodRawShape | undefined, + annotations: ToolAnnotations | undefined, + callback: ToolCallback + ): RegisteredTool { + const registeredTool: RegisteredTool = { + description, + inputSchema: + inputSchema === undefined ? undefined : z.object(inputSchema), + outputSchema: + outputSchema === undefined ? undefined : z.object(outputSchema), + annotations, + callback, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool + } + if (typeof updates.description !== "undefined") registeredTool.description = updates.description + 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.enabled !== "undefined") registeredTool.enabled = updates.enabled + this.sendToolListChanged() + }, + }; + this._registeredTools[name] = registeredTool; + + this.setToolRequestHandlers(); + this.sendToolListChanged() + + return registeredTool + } + /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ @@ -703,22 +736,9 @@ export class McpServer { description: string, paramsSchema: Args, annotations: ToolAnnotations, - callback: ToolCallback, + cb: ToolCallback, ): RegisteredTool; - /** - * Registers a tool from a config object. - */ - tool( - name: string, - config: { - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - callback: ToolCallback - } - ): RegisteredTool; /** * tool() implementation. Parses arguments passed to overrides defined above. @@ -732,86 +752,69 @@ export class McpServer { let inputSchema: ZodRawShape | undefined; let outputSchema: ZodRawShape | undefined; let annotations: ToolAnnotations | undefined; - let callback: ToolCallback; - if (rest.length == 1 && typeof rest[0] === "object" && rest[0] !== null && 'callback' in rest[0]) { - // New style: tool(name, { description, inputSchema, outputSchema, annotations, callback }) - // Future additions to tool definition should be added to config object. + // Tool properties are passed as separate arguments, with omissions allowed. + // Support for this style is frozen as of protocol version 2025-03-26. Future additions + // to tool definition should *NOT* be added. - const config = rest[0] as { - description?: string; - inputSchema?: ZodRawShape; - outputSchema?: ZodRawShape; - annotations?: ToolAnnotations; - callback: ToolCallback; - }; - ({ description, inputSchema, outputSchema, annotations, callback } = config) ; - - } else { - // Old style: tool properties are passed as separate arguments, with omissions allowed. - // Support for this style is frozen as of protocol version 2025-03-26. Future additions - // to tool definition should *NOT* be added. - - if (typeof rest[0] === "string") { - description = rest.shift() as string; - } - - // Handle the different overload combinations - if (rest.length > 1) { - // We have at least one more arg before the callback - const firstArg = rest[0]; - - if (isZodRawShape(firstArg)) { - // We have a params schema as the first arg - inputSchema = rest.shift() as ZodRawShape; - - // Check if the next arg is potentially annotations - if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { - // Case: tool(name, paramsSchema, annotations, cb) - // Or: tool(name, description, paramsSchema, annotations, cb) - annotations = rest.shift() as ToolAnnotations; - } - } else if (typeof firstArg === "object" && firstArg !== null) { - // Not a ZodRawShape, so must be annotations in this position - // Case: tool(name, annotations, cb) - // Or: tool(name, description, annotations, cb) - annotations = rest.shift() as ToolAnnotations; - } - } - callback = rest[0] as ToolCallback; + if (typeof rest[0] === "string") { + description = rest.shift() as string; } - const registeredTool: RegisteredTool = { - description, - inputSchema: - inputSchema === undefined ? undefined : z.object(inputSchema), - outputSchema: - outputSchema === undefined ? undefined : z.object(outputSchema), - annotations, - callback: callback, - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredTools[name] - if (updates.name) this._registeredTools[updates.name] = registeredTool + // Handle the different overload combinations + if (rest.length > 1) { + // We have at least one more arg before the callback + const firstArg = rest[0]; + + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + inputSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) + annotations = rest.shift() as ToolAnnotations; } - if (typeof updates.description !== "undefined") registeredTool.description = updates.description - 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.enabled !== "undefined") registeredTool.enabled = updates.enabled - this.sendToolListChanged() - }, - }; - this._registeredTools[name] = registeredTool; + } else if (typeof firstArg === "object" && firstArg !== null) { + // Not a ZodRawShape, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } + } + const callback = rest[0] as ToolCallback; - this.setToolRequestHandlers(); - this.sendToolListChanged() + return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback) + } - return registeredTool + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + }, + cb: ToolCallback + ): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } + + const { description, inputSchema, outputSchema, annotations } = config; + + return this._createRegisteredTool( + name, + description, + inputSchema, + outputSchema, + annotations, + cb as ToolCallback + ) } /** From 6b680ca18a2cf7624ae2b042393ded927e2ed58d Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 09:55:18 -0400 Subject: [PATCH 038/194] Move testOutputSchemaServers.ts to client examples directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file is a client implementation that tests outputSchema servers, so it belongs in the client examples directory rather than server examples. Updated file paths in error messages to reflect the new location. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/examples/{server => client}/testOutputSchemaServers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/examples/{server => client}/testOutputSchemaServers.ts (91%) diff --git a/src/examples/server/testOutputSchemaServers.ts b/src/examples/client/testOutputSchemaServers.ts similarity index 91% rename from src/examples/server/testOutputSchemaServers.ts rename to src/examples/client/testOutputSchemaServers.ts index 911e23c1..ba6e6b28 100755 --- a/src/examples/server/testOutputSchemaServers.ts +++ b/src/examples/client/testOutputSchemaServers.ts @@ -12,8 +12,8 @@ async function main() { const serverPath = process.argv[2]; if (!serverPath) { - console.error("Usage: npx tsx testOutputSchemaServers.ts "); - console.error("Example: npx tsx testOutputSchemaServers.ts ./mcpServerOutputSchema.ts"); + console.error("Usage: npx tsx src/examples/client/testOutputSchemaServers.ts "); + console.error("Example: npx tsx src/examples/client/testOutputSchemaServers.ts ../server/mcpServerOutputSchema.ts"); process.exit(1); } From 39b2f654349f5e7196829720f956f1ad71d446cf Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 09:57:20 -0400 Subject: [PATCH 039/194] restore server-side validation for tools returning structuredContent without outputSchema Co-Authored-By: Claude --- src/server/mcp.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index e0de538a..a5f2d0f1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -226,6 +226,14 @@ export class McpServer { `Tool ${request.params.name} has no outputSchema and must return content`, ); } + + // If structuredContent is provided, it's an error + if (result.structuredContent) { + throw new McpError( + ErrorCode.InternalError, + `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ); + } } return result; From f470f0996bd88c039e7867e7d23ce232cff2e32b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 10:16:49 -0400 Subject: [PATCH 040/194] fix type error in testOutputSchemaServers.ts --- src/examples/client/testOutputSchemaServers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/client/testOutputSchemaServers.ts b/src/examples/client/testOutputSchemaServers.ts index ba6e6b28..b2a083a2 100755 --- a/src/examples/client/testOutputSchemaServers.ts +++ b/src/examples/client/testOutputSchemaServers.ts @@ -69,9 +69,9 @@ async function main() { console.log(JSON.stringify(weatherResult.structuredContent, null, 2)); } - if (weatherResult.content) { + if (weatherResult.content && Array.isArray(weatherResult.content)) { console.log("Unstructured content:"); - weatherResult.content.forEach(content => { + weatherResult.content.forEach((content: any) => { if (content.type === "text") { console.log(content.text); } From a19f44ee7566021e7e5f802fb073fc78511503e5 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 10:29:47 -0400 Subject: [PATCH 041/194] kick build From c964c5cf91828046efed1e392a09772e54d974fe Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 10:40:34 -0400 Subject: [PATCH 042/194] tsc being a diva on testOutputSchemaServers.ts --- src/examples/client/testOutputSchemaServers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/client/testOutputSchemaServers.ts b/src/examples/client/testOutputSchemaServers.ts index b2a083a2..5542ec36 100755 --- a/src/examples/client/testOutputSchemaServers.ts +++ b/src/examples/client/testOutputSchemaServers.ts @@ -71,7 +71,7 @@ async function main() { if (weatherResult.content && Array.isArray(weatherResult.content)) { console.log("Unstructured content:"); - weatherResult.content.forEach((content: any) => { + weatherResult.content.forEach(content => { if (content.type === "text") { console.log(content.text); } From d37de9b110e5c72c239ae911faf3851051cd5f49 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 16 May 2025 15:18:28 -0400 Subject: [PATCH 043/194] Add support for message field in progress notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the new optional message field for progress notifications as defined in the MCP specification update. This allows servers to provide descriptive status updates alongside progress values. Changes: - Add optional message field to ProgressSchema in types.ts - Add tests for progress notifications with message field - Update protocol tests to verify message field handling Refs: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/197 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/mcp.test.ts | 93 +++++++++++++++++++++++++++++++++++++ src/shared/protocol.test.ts | 68 +++++++++++++++++++++++++++ src/types.ts | 4 ++ 3 files changed, 165 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 73103208..1b2f4d4b 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -79,6 +79,99 @@ describe("McpServer", () => { } ]) }); + + /*** + * Test: Progress Notification with Message Field + */ + test("should send progress notifications with message field", async () => { + const mcpServer = new McpServer( + { + name: "test server", + version: "1.0", + } + ); + + // Create a tool that sends progress updates + mcpServer.tool( + "long-operation", + "A long running operation with progress updates", + { + steps: z.number().min(1).describe("Number of steps to perform"), + }, + async ({ steps }, { sendNotification, _meta }) => { + const progressToken = _meta?.progressToken; + + if (progressToken) { + // Send progress notification for each step + for (let i = 1; i <= steps; i++) { + await sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: i, + total: steps, + message: `Completed step ${i} of ${steps}`, + }, + }); + } + } + + return { content: [{ type: "text" as const, text: `Operation completed with ${steps} steps` }] }; + } + ); + + const progressUpdates: Array<{ progress: number, total?: number, message?: string }> = []; + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool with progress tracking + await client.request( + { + method: "tools/call", + params: { + name: "long-operation", + arguments: { steps: 3 }, + _meta: { + progressToken: "progress-test-1" + } + } + }, + CallToolResultSchema, + { + onprogress: (progress) => { + progressUpdates.push(progress); + } + } + ); + + // Verify progress notifications were received with message field + expect(progressUpdates).toHaveLength(3); + expect(progressUpdates[0]).toMatchObject({ + progress: 1, + total: 3, + message: "Completed step 1 of 3", + }); + expect(progressUpdates[1]).toMatchObject({ + progress: 2, + total: 3, + message: "Completed step 2 of 3", + }); + expect(progressUpdates[2]).toMatchObject({ + progress: 3, + total: 3, + message: "Completed step 3 of 3", + }); + }); }); describe("ResourceTemplate", () => { diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index fb5ecd13..e0141da1 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -255,6 +255,74 @@ describe("protocol tests", () => { await Promise.resolve(); await expect(requestPromise).resolves.toEqual({ result: "success" }); }); + + test("should handle progress notifications with message field", async () => { + await protocol.connect(transport); + const request = { method: "example", params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + const requestPromise = protocol.request(request, mockSchema, { + timeout: 1000, + onprogress: onProgressMock, + }); + + jest.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: 0, + progress: 25, + total: 100, + message: "Initializing process...", + }, + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 25, + total: 100, + message: "Initializing process...", + }); + + jest.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + method: "notifications/progress", + params: { + progressToken: 0, + progress: 75, + total: 100, + message: "Processing data...", + }, + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 75, + total: 100, + message: "Processing data...", + }); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: "2.0", + id: 0, + result: { result: "success" }, + }); + } + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: "success" }); + }); }); }); diff --git a/src/types.ts b/src/types.ts index 2e31a6b5..bd299c8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -364,6 +364,10 @@ export const ProgressSchema = z * Total number of items to process (or total progress required), if known. */ total: z.optional(z.number()), + /** + * An optional message describing the current progress. + */ + message: z.optional(z.string()), }) .passthrough(); From 00cb9203cd17fcb73284340bb987c2cf9373f87c Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 16 May 2025 16:48:31 -0400 Subject: [PATCH 044/194] fix sse endpoint path preservation --- src/client/sse.test.ts | 6 +++--- src/client/sse.ts | 8 +------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index fc7a86c4..b55c7e78 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -87,7 +87,7 @@ describe("SSEClientTransport", () => { await transport.send(message); // Verify the POST request maintains the custom path - expect(lastServerRequest.url).toBe("/custom/path/messages"); + expect(lastServerRequest.url).toBe("/custom/path/sse"); }); it("handles multiple levels of custom paths", async () => { @@ -107,7 +107,7 @@ describe("SSEClientTransport", () => { await transport.send(message); // Verify the POST request maintains the full custom path - expect(lastServerRequest.url).toBe("/api/v1/custom/deep/path/messages"); + expect(lastServerRequest.url).toBe("/api/v1/custom/deep/path/sse"); }); it("maintains custom path for SSE connection", async () => { @@ -130,7 +130,7 @@ describe("SSEClientTransport", () => { }; await transport.send(message); - expect(lastServerRequest.url).toBe("/custom/path/messages"); + expect(lastServerRequest.url).toBe("/custom/path/sse"); }); it("establishes SSE connection and receives endpoint", async () => { diff --git a/src/client/sse.ts b/src/client/sse.ts index 1e481773..755f5da7 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -147,13 +147,7 @@ export class SSEClientTransport implements Transport { this._endpoint = new URL(messageEvent.data, this._url); // If the original URL had a custom path, preserve it in the endpoint URL - const originalPath = this._url.pathname; - if (originalPath && originalPath !== '/' && originalPath !== '/sse') { - // Extract the base path from the original URL (everything before the /sse suffix) - const basePath = originalPath.replace(/\/sse$/, ''); - // The endpoint should use the same base path but with /messages instead of /sse - this._endpoint.pathname = basePath + '/messages'; - } + this._endpoint.pathname = this._url.pathname; if (this._endpoint.origin !== this._url.origin) { throw new Error( From 82f5f7092461eedf48e4f14805bcddc0be08c92e Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Fri, 16 May 2025 17:45:44 -0400 Subject: [PATCH 045/194] rollback path preservation fix --- src/client/sse.test.ts | 65 ------------------------------------------ src/client/sse.ts | 5 ---- 2 files changed, 70 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index b55c7e78..7b137e82 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -68,71 +68,6 @@ describe("SSEClientTransport", () => { }); describe("connection handling", () => { - it("maintains custom path when constructing endpoint URL", async () => { - // Create a URL with a custom path - const customPathUrl = new URL("/custom/path/sse", baseUrl); - transport = new SSEClientTransport(customPathUrl); - - // Start the transport - await transport.start(); - - // Send a test message to verify the endpoint URL - const message: JSONRPCMessage = { - jsonrpc: "2.0", - id: "test-1", - method: "test", - params: {} - }; - - await transport.send(message); - - // Verify the POST request maintains the custom path - expect(lastServerRequest.url).toBe("/custom/path/sse"); - }); - - it("handles multiple levels of custom paths", async () => { - // Test with a deeper nested path - const nestedPathUrl = new URL("/api/v1/custom/deep/path/sse", baseUrl); - transport = new SSEClientTransport(nestedPathUrl); - - await transport.start(); - - const message: JSONRPCMessage = { - jsonrpc: "2.0", - id: "test-1", - method: "test", - params: {} - }; - - await transport.send(message); - - // Verify the POST request maintains the full custom path - expect(lastServerRequest.url).toBe("/api/v1/custom/deep/path/sse"); - }); - - it("maintains custom path for SSE connection", async () => { - const customPathUrl = new URL("/custom/path/sse", baseUrl); - transport = new SSEClientTransport(customPathUrl); - await transport.start(); - expect(lastServerRequest.url).toBe("/custom/path/sse"); - }); - - it("handles URLs with query parameters", async () => { - const urlWithQuery = new URL("/custom/path/sse?param=value", baseUrl); - transport = new SSEClientTransport(urlWithQuery); - await transport.start(); - - const message: JSONRPCMessage = { - jsonrpc: "2.0", - id: "test-1", - method: "test", - params: {} - }; - - await transport.send(message); - expect(lastServerRequest.url).toBe("/custom/path/sse"); - }); - it("establishes SSE connection and receives endpoint", async () => { transport = new SSEClientTransport(baseUrl); await transport.start(); diff --git a/src/client/sse.ts b/src/client/sse.ts index 755f5da7..5e9f0cf0 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -143,12 +143,7 @@ export class SSEClientTransport implements Transport { const messageEvent = event as MessageEvent; try { - // Use the original URL as the base to resolve the received endpoint this._endpoint = new URL(messageEvent.data, this._url); - - // If the original URL had a custom path, preserve it in the endpoint URL - this._endpoint.pathname = this._url.pathname; - if (this._endpoint.origin !== this._url.origin) { throw new Error( `Endpoint origin does not match connection origin: ${this._endpoint.origin}`, From 3f429895fb923717fe2b15934eeb6a11e2578e64 Mon Sep 17 00:00:00 2001 From: Jerome Date: Fri, 16 May 2025 18:48:07 -0400 Subject: [PATCH 046/194] Bumping 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 59b21bd2..9609041f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.3", + "version": "1.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.3", + "version": "1.11.4", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index c23610b8..69445c34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.3", + "version": "1.11.4", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4cc8ed43b4314bd991d8bf4dcf64674df4bb83b4 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 19 May 2025 13:05:21 -0400 Subject: [PATCH 047/194] Fix Ajv import compatibility issue by downgrading to v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downgrade Ajv from v8.17.1 to v6.12.6 to resolve ESM/CJS module compatibility issues that were causing "does not provide an export named 'Ajv'" errors in projects using this SDK. This ensures consistent import patterns across different module systems. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 100 ++++++-------------------------------------- package.json | 2 +- src/client/index.ts | 5 ++- 3 files changed, 16 insertions(+), 91 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9609041f..40bad9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.11.4", "license": "MIT", "dependencies": { - "ajv": "^8.17.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -1036,30 +1036,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", @@ -2223,15 +2199,15 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -3241,30 +3217,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/espree": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", @@ -3532,8 +3484,7 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -3548,22 +3499,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4944,9 +4879,9 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -5621,7 +5556,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5727,15 +5661,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6518,7 +6443,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 69445c34..c33cad94 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "client": "tsx src/cli.ts client" }, "dependencies": { - "ajv": "^8.17.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", diff --git a/src/client/index.ts b/src/client/index.ts index 70870633..98618a17 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -43,7 +43,8 @@ import { ErrorCode, McpError, } from "../types.js"; -import { Ajv, type ValidateFunction } from "ajv"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; export type ClientOptions = ProtocolOptions & { /** @@ -102,7 +103,7 @@ export class Client< ) { super(options); this._capabilities = options?.capabilities ?? {}; - this._ajv = new Ajv({ strict: false, validateFormats: true }); + this._ajv = new Ajv(); } /** From ac1b80a33158017ca3aaf88f304b970b1019021d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 21 May 2025 13:13:34 +0100 Subject: [PATCH 048/194] feat: Add support for separate Authorization Server / Resource server in server flow (spec: DRAFT-2025-v2) (#503) * add support for .oauth-protected-resource metadata endpoint and www-authenticate * test and types * thread throughs scopes * have example separate AS and RS * make inmemory explicitly demo * fix type * fix types * fix client example * fixup comments * refactor metadata endpoints to cleanup * almost working w/ forwarding * separate AS/RS working * add to readme * de-async the auth server setup for happier top level flow * add test for new router * fix test * remove redundant comment * clarify comment * remove more unused endpoints * simplify router * slim down * simplify verifier, and fix router test * fix doc string --- src/examples/README.md | 3 + .../server/demoInMemoryOAuthProvider.ts | 200 ++++++++++++++++++ src/examples/server/simpleStreamableHttp.ts | 117 +++++++++- src/server/auth/handlers/metadata.ts | 6 +- src/server/auth/middleware/bearerAuth.test.ts | 163 +++++++++++--- src/server/auth/middleware/bearerAuth.ts | 30 ++- src/server/auth/provider.ts | 23 +- src/server/auth/router.test.ts | 127 ++++++++++- src/server/auth/router.ts | 159 +++++++++++--- src/shared/auth.ts | 41 +++- 10 files changed, 787 insertions(+), 82 deletions(-) create mode 100644 src/examples/server/demoInMemoryOAuthProvider.ts diff --git a/src/examples/README.md b/src/examples/README.md index 611c081e..e5654b9d 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -67,6 +67,9 @@ A server that implements the Streamable HTTP transport (protocol version 2025-03 ```bash 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 ``` ##### JSON Response Mode Server diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts new file mode 100644 index 00000000..024208d6 --- /dev/null +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -0,0 +1,200 @@ +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 express, { Request, Response } from "express"; +import { AuthInfo } from 'src/server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; + + +export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { + private clients = new Map(); + + async getClient(clientId: string) { + return this.clients.get(clientId); + } + + async registerClient(clientMetadata: OAuthClientInformationFull) { + this.clients.set(clientMetadata.client_id, clientMetadata); + return clientMetadata; + } +} + +/** + * 🚨 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(); + private tokens = new Map(); + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const code = randomUUID(); + + const searchParams = new URLSearchParams({ + code, + }); + if (params.state !== undefined) { + searchParams.set('state', params.state); + } + + this.codes.set(code, { + client, + params + }); + + const targetUrl = new URL(client.redirect_uris[0]); + targetUrl.search = searchParams.toString(); + res.redirect(targetUrl.toString()); + } + + async challengeForAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string + ): Promise { + + // Store the challenge with the code data + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + return codeData.params.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + // Note: code verifier is checked in token.ts by default + // it's unused here for that reason. + _codeVerifier?: string + ): Promise { + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + if (codeData.client.client_id !== client.client_id) { + throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); + } + + this.codes.delete(authorizationCode); + const token = randomUUID(); + + const tokenData = { + token, + clientId: client.client_id, + scopes: codeData.params.scopes || [], + expiresAt: Date.now() + 3600000, // 1 hour + type: 'access' + }; + + this.tokens.set(token, tokenData); + + return { + access_token: token, + token_type: 'bearer', + expires_in: 3600, + scope: (codeData.params.scopes || []).join(' '), + }; + } + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + _refreshToken: string, + _scopes?: string[] + ): Promise { + throw new Error('Not implemented for example demo'); + } + + async verifyAccessToken(token: string): Promise { + const tokenData = this.tokens.get(token); + if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { + throw new Error('Invalid or expired token'); + } + + return { + token, + clientId: tokenData.clientId, + scopes: tokenData.scopes, + expiresAt: Math.floor(tokenData.expiresAt / 1000), + }; + } +} + + +export const setupAuthServer = (authServerUrl: 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(); + const authApp = express(); + authApp.use(express.json()); + // For introspection requests + authApp.use(express.urlencoded()); + + // Add OAuth routes to the auth server + // NOTE: this will also add a protected resource metadata route, + // but it won't be used, so leave it. + authApp.use(mcpAuthRouter({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], + })); + + authApp.post('/introspect', async (req: Request, res: Response) => { + try { + const { token } = req.body; + if (!token) { + res.status(400).json({ error: 'Token is required' }); + return; + } + + const tokenInfo = await provider.verifyAccessToken(token); + res.json({ + active: true, + client_id: tokenInfo.clientId, + scope: tokenInfo.scopes.join(' '), + exp: tokenInfo.expiresAt + }); + return + } catch (error) { + res.status(401).json({ + active: false, + error: 'Unauthorized', + error_description: `Invalid token: ${error}` + }); + } + }); + + const auth_port = authServerUrl.port; + // Start the auth server + authApp.listen(auth_port, () => { + console.log(`OAuth Authorization Server listening on port ${auth_port}`); + }); + + // Note: we could fetch this from the server, but then we end up + // with some top level async which gets annoying. + const oauthMetadata: OAuthMetadata = createOAuthMetadata({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'], + }) + + oauthMetadata.introspection_endpoint = new URL("/introspect", authServerUrl).href; + + return oauthMetadata; +} diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 1933cc94..6c331192 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,8 +3,15 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { OAuthMetadata } from 'src/shared/auth.js'; + +// Check for OAuth flag +const useOAuth = process.argv.includes('--oauth'); // Create an MCP server with implementation details const getServer = () => { @@ -40,7 +47,7 @@ const getServer = () => { name: z.string().describe('Name to greet'), }, { - title: 'Multiple Greeting Tool', + title: 'Multiple Greeting Tool', readOnlyHint: true, openWorldHint: false }, @@ -159,14 +166,79 @@ const getServer = () => { return server; }; +const MCP_PORT = 3000; +const AUTH_PORT = 3001; + const app = express(); app.use(express.json()); +// Set up OAuth if enabled +let authMiddleware = null; +if (useOAuth) { + // Create auth middleware for MCP endpoints + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); + const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + + const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl); + + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + const endpoint = oauthMetadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + + if (!response.ok) { + throw new Error(`Invalid or expired token: ${await response.text()}`); + } + + const data = await response.json(); + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp, + }; + } + } + // Add metadata routes to the main MCP server + app.use(mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server', + })); + + authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + requiredScopes: ['mcp:tools'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + }); +} + // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -app.post('/mcp', async (req: Request, res: Response) => { +// MCP POST endpoint with optional auth +const mcpPostHandler = async (req: Request, res: Response) => { console.log('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; @@ -234,16 +306,27 @@ app.post('/mcp', async (req: Request, res: Response) => { }); } } -}); +}; + +// Set up routes with conditional auth middleware +if (useOAuth && authMiddleware) { + app.post('/mcp', authMiddleware, mcpPostHandler); +} else { + app.post('/mcp', mcpPostHandler); +} // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { +const mcpGetHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } + if (useOAuth && req.auth) { + console.log('Authenticated SSE connection from user:', req.auth); + } + // Check for Last-Event-ID header for resumability const lastEventId = req.headers['last-event-id'] as string | undefined; if (lastEventId) { @@ -254,10 +337,17 @@ app.get('/mcp', async (req: Request, res: Response) => { const transport = transports[sessionId]; await transport.handleRequest(req, res); -}); +}; + +// Set up GET route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.get('/mcp', authMiddleware, mcpGetHandler); +} else { + app.get('/mcp', mcpGetHandler); +} // Handle DELETE requests for session termination (according to MCP spec) -app.delete('/mcp', async (req: Request, res: Response) => { +const mcpDeleteHandler = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); @@ -275,12 +365,17 @@ app.delete('/mcp', async (req: Request, res: Response) => { res.status(500).send('Error processing session termination'); } } -}); +}; -// Start the server -const PORT = 3000; -app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); +// Set up DELETE route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.delete('/mcp', authMiddleware, mcpDeleteHandler); +} else { + app.delete('/mcp', mcpDeleteHandler); +} + +app.listen(MCP_PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); // Handle server shutdown diff --git a/src/server/auth/handlers/metadata.ts b/src/server/auth/handlers/metadata.ts index 048a4d4a..444b8505 100644 --- a/src/server/auth/handlers/metadata.ts +++ b/src/server/auth/handlers/metadata.ts @@ -1,9 +1,9 @@ import express, { RequestHandler } from "express"; -import { OAuthMetadata } from "../../../shared/auth.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js"; import cors from 'cors'; import { allowedMethods } from "../middleware/allowedMethods.js"; -export function metadataHandler(metadata: OAuthMetadata): RequestHandler { +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler { }); return router; -} \ No newline at end of file +} diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 43cbfa0a..b8953e5c 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -2,17 +2,11 @@ import { Request, Response } from "express"; import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; -import { OAuthServerProvider } from "../provider.js"; -import { OAuthRegisteredClientsStore } from "../clients.js"; +import { OAuthTokenVerifier } from "../provider.js"; -// Mock provider +// Mock verifier const mockVerifyAccessToken = jest.fn(); -const mockProvider: OAuthServerProvider = { - clientsStore: {} as OAuthRegisteredClientsStore, - authorize: jest.fn(), - challengeForAuthorizationCode: jest.fn(), - exchangeAuthorizationCode: jest.fn(), - exchangeRefreshToken: jest.fn(), +const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken, }; @@ -50,7 +44,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer valid-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -59,7 +53,7 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.status).not.toHaveBeenCalled(); expect(mockResponse.json).not.toHaveBeenCalled(); }); - + it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", @@ -73,7 +67,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer expired-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); @@ -87,7 +81,7 @@ describe("requireBearerAuth middleware", () => { ); expect(nextFunction).not.toHaveBeenCalled(); }); - + it("should accept non-expired tokens", async () => { const nonExpiredAuthInfo: AuthInfo = { token: "valid-token", @@ -101,7 +95,7 @@ describe("requireBearerAuth middleware", () => { authorization: "Bearer valid-token", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -124,7 +118,7 @@ describe("requireBearerAuth middleware", () => { }; const middleware = requireBearerAuth({ - provider: mockProvider, + verifier: mockVerifier, requiredScopes: ["read", "write"] }); @@ -155,7 +149,7 @@ describe("requireBearerAuth middleware", () => { }; const middleware = requireBearerAuth({ - provider: mockProvider, + verifier: mockVerifier, requiredScopes: ["read", "write"] }); @@ -169,7 +163,7 @@ describe("requireBearerAuth middleware", () => { }); it("should return 401 when no Authorization header is present", async () => { - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).not.toHaveBeenCalled(); @@ -189,7 +183,7 @@ describe("requireBearerAuth middleware", () => { authorization: "InvalidFormat", }; - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).not.toHaveBeenCalled(); @@ -214,7 +208,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); @@ -236,7 +230,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -258,7 +252,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -276,7 +270,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -294,7 +288,7 @@ describe("requireBearerAuth middleware", () => { mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); - const middleware = requireBearerAuth({ provider: mockProvider }); + const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); @@ -304,4 +298,125 @@ describe("requireBearerAuth middleware", () => { ); expect(nextFunction).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + describe("with resourceMetadataUrl", () => { + const resourceMetadataUrl = "https://api.example.com/.well-known/oauth-protected-resource"; + + it("should include resource_metadata in WWW-Authenticate header for 401 responses", async () => { + mockRequest.headers = {}; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata in WWW-Authenticate header when token verification fails", async () => { + mockRequest.headers = { + authorization: "Bearer invalid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata in WWW-Authenticate header for insufficient scope errors", async () => { + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: admin")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata when token is expired", async () => { + const expiredAuthInfo: AuthInfo = { + token: "expired-token", + clientId: "client-123", + scopes: ["read", "write"], + expiresAt: Math.floor(Date.now() / 1000) - 100, + }; + mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); + + mockRequest.headers = { + authorization: "Bearer expired-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should include resource_metadata when scope check fails", async () => { + const authInfo: AuthInfo = { + token: "valid-token", + clientId: "client-123", + scopes: ["read"], + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ["read", "write"], + resourceMetadataUrl + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + `Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it("should not affect server errors (no WWW-Authenticate header)", async () => { + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); + + const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.set).not.toHaveBeenCalledWith("WWW-Authenticate", expect.anything()); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index cd1b314a..fd96055a 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -1,18 +1,23 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; -import { OAuthServerProvider } from "../provider.js"; +import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; export type BearerAuthMiddlewareOptions = { /** * A provider used to verify tokens. */ - provider: OAuthServerProvider; + verifier: OAuthTokenVerifier; /** * Optional scopes that the token must have. */ requiredScopes?: string[]; + + /** + * Optional resource metadata URL to include in WWW-Authenticate header. + */ + resourceMetadataUrl?: string; }; declare module "express-serve-static-core" { @@ -26,10 +31,13 @@ declare module "express-serve-static-core" { /** * Middleware that requires a valid Bearer token in the Authorization header. - * + * * This will validate the token with the auth provider and add the resulting auth info to the request object. + * + * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header + * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. */ -export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler { +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -42,7 +50,7 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - const authInfo = await provider.verifyAccessToken(token); + const authInfo = await verifier.verifyAccessToken(token); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { @@ -64,10 +72,16 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM next(); } catch (error) { if (error instanceof InvalidTokenError) { - res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + const wwwAuthValue = resourceMetadataUrl + ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` + : `Bearer error="${error.errorCode}", error_description="${error.message}"`; + res.set("WWW-Authenticate", wwwAuthValue); res.status(401).json(error.toResponseObject()); } else if (error instanceof InsufficientScopeError) { - res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + const wwwAuthValue = resourceMetadataUrl + ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` + : `Bearer error="${error.errorCode}", error_description="${error.message}"`; + res.set("WWW-Authenticate", wwwAuthValue); res.status(403).json(error.toResponseObject()); } else if (error instanceof ServerError) { res.status(500).json(error.toResponseObject()); @@ -80,4 +94,4 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM } } }; -} \ No newline at end of file +} diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index dc186bca..8a0bf0f1 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -20,8 +20,8 @@ export interface OAuthServerProvider { get clientsStore(): OAuthRegisteredClientsStore; /** - * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. - * + * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. + * * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. @@ -50,17 +50,28 @@ export interface OAuthServerProvider { /** * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). - * + * * If the given token is invalid or already revoked, this method should do nothing. */ revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; /** * Whether to skip local PKCE validation. - * + * * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. - * + * * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. */ skipLocalPkceValidation?: boolean; -} \ No newline at end of file +} + + +/** + * Slim implementation useful for token verification + */ +export interface OAuthTokenVerifier { + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; +} diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 86eda221..bcf0a51a 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -1,12 +1,13 @@ -import { mcpAuthRouter, AuthRouterOptions } from './router.js'; +import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from './router.js'; import { OAuthServerProvider, AuthorizationParams } from './provider.js'; import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; import express, { Response } from 'express'; import supertest from 'supertest'; import { AuthInfo } from './types.js'; import { InvalidTokenError } from './errors.js'; + describe('MCP Auth Router', () => { // Setup mock provider with full capabilities const mockClientStore: OAuthRegisteredClientsStore = { @@ -246,6 +247,29 @@ describe('MCP Auth Router', () => { expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); expect(response.body.service_documentation).toBeUndefined(); }); + + it('provides protected resource metadata', async () => { + // Setup router with draft protocol version + const draftApp = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://mcp.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + draftApp.use(mcpAuthRouter(options)); + + const response = await supertest(draftApp) + .get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://mcp.example.com/'); + expect(response.body.authorization_servers).toContain('https://mcp.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + }); }); describe('Endpoint routing', () => { @@ -358,4 +382,101 @@ describe('MCP Auth Router', () => { expect(revokeResponse.status).toBe(404); }); }); -}); \ No newline at end of file +}); + +describe('MCP Auth Metadata Router', () => { + + const mockOAuthMetadata : OAuthMetadata = { + issuer: 'https://auth.example.com/', + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + } + + describe('Router creation', () => { + it('successfully creates router with valid options', () => { + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + }; + + expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoints', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + serviceDocumentationUrl: new URL('https://docs.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + }; + app.use(mcpAuthMetadataRouter(options)); + }); + + it('returns OAuth authorization server metadata', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify metadata points to authorization server + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + }); + + it('returns OAuth protected resource metadata', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://api.example.com/'); + expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); + expect(response.body.resource_documentation).toBe('https://docs.example.com/'); + }); + + it('works with minimal configuration', async () => { + const minimalApp = express(); + const options: AuthMetadataOptions = { + oauthMetadata: mockOAuthMetadata, + resourceServerUrl: new URL('https://api.example.com'), + }; + minimalApp.use(mcpAuthMetadataRouter(options)); + + const authResponse = await supertest(minimalApp) + .get('/.well-known/oauth-authorization-server'); + + expect(authResponse.status).toBe(200); + expect(authResponse.body.issuer).toBe('https://auth.example.com/'); + expect(authResponse.body.service_documentation).toBeUndefined(); + expect(authResponse.body.scopes_supported).toBeUndefined(); + + const resourceResponse = await supertest(minimalApp) + .get('/.well-known/oauth-protected-resource'); + + expect(resourceResponse.status).toBe(200); + expect(resourceResponse.body.resource).toBe('https://api.example.com/'); + expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(resourceResponse.body.scopes_supported).toBeUndefined(); + expect(resourceResponse.body.resource_name).toBeUndefined(); + expect(resourceResponse.body.resource_documentation).toBeUndefined(); + }); + }); +}); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 49d451c2..3e752e7a 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -5,6 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/au import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js"; import { metadataHandler } from "./handlers/metadata.js"; import { OAuthServerProvider } from "./provider.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../shared/auth.js"; export type AuthRouterOptions = { /** @@ -19,7 +20,7 @@ export type AuthRouterOptions = { /** * The base URL of the authorization server to use for the metadata endpoints. - * + * * If not provided, the issuer URL will be used as the base URL. */ baseUrl?: URL; @@ -29,6 +30,17 @@ export type AuthRouterOptions = { */ serviceDocumentationUrl?: URL; + /** + * An optional list of scopes supported by this authorization server + */ + scopesSupported?: string[]; + + + /** + * The resource name to be displayed in protected resource metadata + */ + resourceName?: string; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; @@ -36,37 +48,37 @@ export type AuthRouterOptions = { tokenOptions?: Omit; }; -/** - * Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. - * - * By default, rate limiting is applied to all endpoints to prevent abuse. - * - * This router MUST be installed at the application root, like so: - * - * const app = express(); - * app.use(mcpAuthRouter(...)); - */ -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { - const issuer = options.issuerUrl; - const baseUrl = options.baseUrl; - +const checkIssuerUrl = (issuer: URL): void => { // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") { throw new Error("Issuer URL must be HTTPS"); } if (issuer.hash) { - throw new Error("Issuer URL must not have a fragment"); + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); } if (issuer.search) { - throw new Error("Issuer URL must not have a query string"); + throw new Error(`Issuer URL must not have a query string: ${issuer}`); } +} + +export const createOAuthMetadata = (options: { + provider: OAuthServerProvider, + issuerUrl: URL, + baseUrl?: URL + serviceDocumentationUrl?: URL, + scopesSupported?: string[]; +}): OAuthMetadata => { + const issuer = options.issuerUrl; + const baseUrl = options.baseUrl; + + checkIssuerUrl(issuer); const authorization_endpoint = "/authorize"; const token_endpoint = "/token"; const registration_endpoint = options.provider.clientsStore.registerClient ? "/register" : undefined; const revocation_endpoint = options.provider.revokeToken ? "/revoke" : undefined; - const metadata = { + const metadata: OAuthMetadata = { issuer: issuer.href, service_documentation: options.serviceDocumentationUrl?.href, @@ -78,29 +90,56 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { token_endpoint_auth_methods_supported: ["client_secret_post"], grant_types_supported: ["authorization_code", "refresh_token"], + scopes_supported: options.scopesSupported, + revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, revocation_endpoint_auth_methods_supported: revocation_endpoint ? ["client_secret_post"] : undefined, registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, }; + return metadata +} + +/** + * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). + * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. + * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. + * + * By default, rate limiting is applied to all endpoints to prevent abuse. + * + * This router MUST be installed at the application root, like so: + * + * const app = express(); + * app.use(mcpAuthRouter(...)); + */ +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { + const oauthMetadata = createOAuthMetadata(options); + const router = express.Router(); router.use( - authorization_endpoint, + new URL(oauthMetadata.authorization_endpoint).pathname, authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) ); router.use( - token_endpoint, + new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }) ); - router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); + router.use(mcpAuthMetadataRouter({ + oauthMetadata, + // This router is used for AS+RS combo's, so the issuer is also the resource server + resourceServerUrl: new URL(oauthMetadata.issuer), + serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName + })); - if (registration_endpoint) { + if (oauthMetadata.registration_endpoint) { router.use( - registration_endpoint, + new URL(oauthMetadata.registration_endpoint).pathname, clientRegistrationHandler({ clientsStore: options.provider.clientsStore, ...options, @@ -108,12 +147,80 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { ); } - if (revocation_endpoint) { + if (oauthMetadata.revocation_endpoint) { router.use( - revocation_endpoint, + new URL(oauthMetadata.revocation_endpoint).pathname, revocationHandler({ provider: options.provider, ...options.revocationOptions }) ); } return router; -} \ No newline at end of file +} + +export type AuthMetadataOptions = { + /** + * OAuth Metadata as would be returned from the authorization server + * this MCP server relies on + */ + oauthMetadata: OAuthMetadata; + + /** + * The url of the MCP server, for use in protected resource metadata + */ + resourceServerUrl: URL; + + /** + * The url for documentation for the MCP server + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this MCP server + */ + scopesSupported?: string[]; + + /** + * An optional resource name to display in resource metadata + */ + resourceName?: string; +} + +export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { + checkIssuerUrl(new URL(options.oauthMetadata.issuer)); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.resourceServerUrl.href, + + authorization_servers: [ + options.oauthMetadata.issuer + ], + + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href, + }; + + router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata)); + + // Always add this for backwards compatibility + router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata)); + + return router; +} + +/** + * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL + * from a given server URL. This replaces the path with the standard metadata endpoint. + * + * @param serverUrl - The base URL of the protected resource server + * @returns The URL for the OAuth protected resource metadata endpoint + * + * @example + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource' + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { + return new URL('/.well-known/oauth-protected-resource', serverUrl).href; +} diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 60a28b80..d28cfa9d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -109,6 +109,44 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); +/** + * RFC 9728 OAuth Protected Resource Metadata + */ + export const OAuthProtectedResourceMetadataSchema = z.object({ + // REQUIRED fields + resource: z.string().url(), + + // OPTIONAL fields + authorization_servers: z.array(z.string().url()).optional(), + + jwks_uri: z.string().url().optional(), + + scopes_supported: z.array(z.string()).optional(), + + bearer_methods_supported: z.array(z.string()).optional(), + + resource_signing_alg_values_supported: z.array(z.string()).optional(), + + resource_name: z.string().optional(), + + resource_documentation: z.string().url().optional(), + + resource_policy_uri: z.string().url().optional(), + + resource_tos_uri: z.string().url().optional(), + + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + + authorization_details_types_supported: z.array(z.string()).optional(), + + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + + dpop_bound_access_tokens_required: z.boolean().optional(), + + // Signed metadata JWT + signed_metadata: z.string().optional() + }).strict(); + export type OAuthMetadata = z.infer; export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; @@ -116,4 +154,5 @@ export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; export type OAuthClientInformationFull = z.infer; export type OAuthClientRegistrationError = z.infer; -export type OAuthTokenRevocationRequest = z.infer; \ No newline at end of file +export type OAuthTokenRevocationRequest = z.infer; +export type OAuthProtectedResourceMetadata = z.infer; From 69dbfac0e4c8714926ccbd45e2a1f8677c7a85f3 Mon Sep 17 00:00:00 2001 From: Itsuki Date: Wed, 21 May 2025 22:55:38 +0900 Subject: [PATCH 049/194] Feat: Separate authorization server and resource server on client auth flow (#416) * add type and schema for protected resource metadata * add private variable to keep track of authorization server url to avoid fetching Protected Resource Metadata every time * change auth flow to use authroization server and add function to discover protected resource metadata * add/modify tests for new added auth functions * modify test to use the authorization server * remove unused variable * add resource parameter when building auth url * Change resource to use the protected resource metadata * resolve typo. Co-authored-by: Paul Carleton * Update src/client/sse.ts import. Co-authored-by: Paul Carleton * remove log. Co-authored-by: Paul Carleton * Update section title. Co-authored-by: Paul Carleton * remove log. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * remove resource parameter. Co-authored-by: Paul Carleton * make backwards compatibile * s/resourceServerUrl/serverUrl/g for backwards compat * fix test comment --------- Co-authored-by: itsuki Co-authored-by: Paul Carleton Co-authored-by: Paul Carleton --- src/client/auth.test.ts | 261 ++++++++++++++++++++++++++ src/client/auth.ts | 137 +++++++++++--- src/client/sse.test.ts | 346 +++++++++++++++++++++++++---------- src/client/sse.ts | 23 ++- src/client/streamableHttp.ts | 21 ++- src/shared/auth.ts | 59 +++--- 6 files changed, 675 insertions(+), 172 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eba7074b..eb01d50d 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -4,6 +4,10 @@ import { exchangeAuthorization, refreshAuthorization, registerClient, + discoverOAuthProtectedResourceMetadata, + extractResourceMetadataUrl, + auth, + type OAuthClientProvider, } from "./auth.js"; // Mock fetch globally @@ -15,6 +19,165 @@ describe("OAuth Authorization", () => { mockFetch.mockReset(); }); + describe("extractResourceMetadataUrl", () => { + it("returns resource metadata url when present", async () => { + const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toEqual(new URL(resourceUrl)); + }); + + it("returns undefined if not bearer", async () => { + const resourceUrl = "https://resource.example.com/.well-known/oauth-protected-resource" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it("returns undefined if resource_metadata not present", async () => { + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it("returns undefined on invalid url", async () => { + const resourceUrl = "invalid-url" + const mockResponse = { + headers: { + get: jest.fn((name) => name === "WWW-Authenticate" ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` : null), + } + } as unknown as Response + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + }); + + describe("discoverOAuthProtectedResourceMetadata", () => { + const validMetadata = { + resource: "https://resource.example.com", + authorization_servers: ["https://auth.example.com"], + }; + + it("returns metadata when discovery succeeds", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com"); + 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"); + }); + + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call with MCP header - fail with TypeError (simulating CORS error) + // We need to use TypeError specifically because that's what the implementation checks for + return Promise.reject(new TypeError("Network error")); + } else { + // Second call without header - succeed + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validMetadata + }); + } + }); + + // Should succeed with the second call + const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com"); + expect(metadata).toEqual(validMetadata); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify first call had MCP header + expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty("MCP-Protocol-Version"); + }); + + it("throws an error when all fetch attempts fail", async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call - fail with TypeError + return Promise.reject(new TypeError("First failure")); + } else { + // Second call - fail with different error + return Promise.reject(new Error("Second failure")); + } + }); + + // Should fail with the second error + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("Second failure"); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("throws on 404 errors", async () => { + 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."); + }); + + it("throws on non-404 errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow("HTTP 500"); + }); + + it("validates metadata schema", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + scopes_supported: ["email", "mcp"], + }), + }); + + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + .rejects.toThrow(); + }); + }); + describe("discoverOAuthMetadata", () => { const validMetadata = { issuer: "https://auth.example.com", @@ -158,6 +321,7 @@ describe("OAuth Authorization", () => { const { authorizationUrl, codeVerifier } = await startAuthorization( "https://auth.example.com", { + metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", } @@ -503,4 +667,101 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); }); + + describe("auth function", () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata", async () => { + // Setup: First call to protected resource metadata fails (404) + // Second call to auth server metadata succeeds + let callCount = 0; + mockFetch.mockImplementation((url) => { + callCount++; + + const urlString = url.toString(); + + if (callCount === 1 && urlString.includes("/.well-known/oauth-protected-resource")) { + // First call - protected resource metadata fails with 404 + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (callCount === 2 && urlString.includes("/.well-known/oauth-authorization-server")) { + // Second call - auth server metadata succeeds + 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", + registration_endpoint: "https://auth.example.com/register", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (callCount === 3 && urlString.includes("/register")) { + // Third call - client registration succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: "test-client-id", + client_secret: "test-client-secret", + client_id_issued_at: 1612137600, + client_secret_expires_at: 1612224000, + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }), + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = jest.fn(); + + // Call the auth function + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + }); + + // Verify the result + expect(result).toBe("REDIRECT"); + + // Verify the sequence of calls + expect(mockFetch).toHaveBeenCalledTimes(3); + + // First call should be to protected resource metadata + expect(mockFetch.mock.calls[0][0].toString()).toBe( + "https://resource.example.com/.well-known/oauth-protected-resource" + ); + + // Second call should be to oauth metadata + expect(mockFetch.mock.calls[1][0].toString()).toBe( + "https://resource.example.com/.well-known/oauth-authorization-server" + ); + }); + }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index e4941576..b96d6b0e 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -1,11 +1,11 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; -import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js"; -import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; +import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. - * + * * This client relies upon a concept of an authorized "session," the exact * meaning of which is application-defined. Tokens, authorization codes, and * code verifiers should not cross different sessions. @@ -32,7 +32,7 @@ export interface OAuthClientProvider { * If implemented, this permits the OAuth client to dynamically register with * the server. Client information saved this way should later be read via * `clientInformation()`. - * + * * This method is not required to be implemented if client information is * statically known (e.g., pre-registered). */ @@ -78,7 +78,7 @@ export class UnauthorizedError extends Error { /** * Orchestrates the full auth flow with a server. - * + * * This can be used as a single entry point for all authorization functionality, * instead of linking together the other lower-level functions in this module. */ @@ -87,12 +87,26 @@ export async function auth( { serverUrl, authorizationCode, scope, + resourceMetadataUrl }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - }): Promise { - const metadata = await discoverOAuthMetadata(serverUrl); + resourceMetadataUrl?: URL }): Promise { + + let authorizationServerUrl = serverUrl; + try { + const resourceMetadata = await discoverOAuthProtectedResourceMetadata( + resourceMetadataUrl || serverUrl); + + 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) + } + + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); @@ -105,7 +119,7 @@ export async function auth( throw new Error("OAuth client information must be saveable for dynamic registration"); } - const fullInformation = await registerClient(serverUrl, { + const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, }); @@ -117,7 +131,7 @@ export async function auth( // Exchange authorization code for tokens if (authorizationCode !== undefined) { const codeVerifier = await provider.codeVerifier(); - const tokens = await exchangeAuthorization(serverUrl, { + const tokens = await exchangeAuthorization(authorizationServerUrl, { metadata, clientInformation, authorizationCode, @@ -135,7 +149,7 @@ export async function auth( if (tokens?.refresh_token) { try { // Attempt to refresh the token - const newTokens = await refreshAuthorization(serverUrl, { + const newTokens = await refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken: tokens.refresh_token, @@ -149,7 +163,7 @@ export async function auth( } // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { + const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, redirectUrl: provider.redirectUrl, @@ -161,6 +175,82 @@ export async function auth( return "REDIRECT"; } +/** + * Extract resource_metadata from response header. + */ +export function extractResourceMetadataUrl(res: Response): URL | undefined { + + const authenticateHeader = res.headers.get("WWW-Authenticate"); + if (!authenticateHeader) { + return 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="([^"]*)"/; + const match = regex.exec(authenticateHeader); + + if (!match) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + console.log("Invalid resource metadata url: ", match[1]); + return undefined; + } +} + +/** + * Looks up RFC 9728 OAuth 2.0 Protected Resource 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 discoverOAuthProtectedResourceMetadata( + serverUrl: string | URL, + opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL }, +): Promise { + + let url: URL + if (opts?.resourceMetadataUrl) { + url = new URL(opts?.resourceMetadataUrl); + } else { + url = new URL("/.well-known/oauth-protected-resource", serverUrl); + } + + 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) { + throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + } + + if (!response.ok) { + throw new Error( + `HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`, + ); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + /** * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. * @@ -168,10 +258,10 @@ export async function auth( * return `undefined`. Any other errors will be thrown as exceptions. */ export async function discoverOAuthMetadata( - serverUrl: string | URL, + authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const url = new URL("/.well-known/oauth-authorization-server", serverUrl); + const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); let response: Response; try { response = await fetch(url, { @@ -205,7 +295,7 @@ export async function discoverOAuthMetadata( * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ export async function startAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -240,7 +330,7 @@ export async function startAuthorization( ); } } else { - authorizationUrl = new URL("/authorize", serverUrl); + authorizationUrl = new URL("/authorize", authorizationServerUrl); } // Generate PKCE challenge @@ -256,7 +346,7 @@ export async function startAuthorization( codeChallengeMethod, ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); - + if (scope) { authorizationUrl.searchParams.set("scope", scope); } @@ -268,7 +358,7 @@ export async function startAuthorization( * Exchanges an authorization code for an access token with the given server. */ export async function exchangeAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -298,7 +388,7 @@ export async function exchangeAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange code for tokens @@ -333,7 +423,7 @@ export async function exchangeAuthorization( * Exchange a refresh token for an updated access token. */ export async function refreshAuthorization( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientInformation, @@ -359,7 +449,7 @@ export async function refreshAuthorization( ); } } else { - tokenUrl = new URL("/token", serverUrl); + tokenUrl = new URL("/token", authorizationServerUrl); } // Exchange refresh token @@ -380,7 +470,6 @@ export async function refreshAuthorization( }, body: params, }); - if (!response.ok) { throw new Error(`Token refresh failed: HTTP ${response.status}`); } @@ -392,7 +481,7 @@ export async function refreshAuthorization( * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ export async function registerClient( - serverUrl: string | URL, + authorizationServerUrl: string | URL, { metadata, clientMetadata, @@ -410,7 +499,7 @@ export async function registerClient( registrationUrl = new URL(metadata.registration_endpoint); } else { - registrationUrl = new URL("/register", serverUrl); + registrationUrl = new URL("/register", authorizationServerUrl); } const response = await fetch(registrationUrl, { @@ -426,4 +515,4 @@ export async function registerClient( } return OAuthClientInformationFullSchema.parse(await response.json()); -} \ No newline at end of file +} diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 7b137e82..714e1fdd 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -6,9 +6,11 @@ import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { OAuthTokens } from "../shared/auth.js"; describe("SSEClientTransport", () => { - let server: Server; + let resourceServer: Server; + let authServer: Server; let transport: SSEClientTransport; - let baseUrl: URL; + let resourceBaseUrl: URL; + let authBaseUrl: URL; let lastServerRequest: IncomingMessage; let sendServerMessage: ((message: string) => void) | null = null; @@ -17,8 +19,26 @@ describe("SSEClientTransport", () => { lastServerRequest = null as unknown as IncomingMessage; sendServerMessage = null; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(200, { + "Content-Type": "application/json" + }); + res.end(JSON.stringify({ + 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"], + })); + return; + } + res.writeHead(401).end(); + }); + // Create a test server that will receive the EventSource connection - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { lastServerRequest = req; // Send SSE headers @@ -30,7 +50,7 @@ describe("SSEClientTransport", () => { // Send the endpoint event res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); // Store reference to send function for tests sendServerMessage = (message: string) => { @@ -51,9 +71,9 @@ describe("SSEClientTransport", () => { }); // Start server on random port - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as AddressInfo; - baseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); done(); }); @@ -62,14 +82,15 @@ describe("SSEClientTransport", () => { afterEach(async () => { await transport.close(); - await server.close(); + await resourceServer.close(); + await authServer.close(); jest.clearAllMocks(); }); describe("connection handling", () => { it("establishes SSE connection and receives endpoint", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); expect(lastServerRequest.headers.accept).toBe("text/event-stream"); @@ -78,27 +99,27 @@ describe("SSEClientTransport", () => { it("rejects if server returns non-200 status", async () => { // Create a server that returns 403 - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { res.writeHead(403); res.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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await expect(transport.start()).rejects.toThrow(); }); it("closes EventSource connection on close()", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const closePromise = new Promise((resolve) => { @@ -113,7 +134,7 @@ describe("SSEClientTransport", () => { describe("message handling", () => { it("receives and parses JSON-RPC messages", async () => { const receivedMessages: JSONRPCMessage[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onmessage = (msg) => receivedMessages.push(msg); await transport.start(); @@ -136,7 +157,7 @@ describe("SSEClientTransport", () => { it("handles malformed JSON messages", async () => { const errors: Error[] = []; - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); transport.onerror = (err) => errors.push(err); await transport.start(); @@ -151,7 +172,7 @@ describe("SSEClientTransport", () => { }); it("handles messages via POST requests", async () => { - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -179,9 +200,9 @@ describe("SSEClientTransport", () => { it("handles POST request failures", async () => { // Create a server that returns 500 for POST - await server.close(); + await resourceServer.close(); - server = createServer((req, res) => { + resourceServer = createServer((req, res) => { if (req.method === "GET") { res.writeHead(200, { "Content-Type": "text/event-stream", @@ -189,7 +210,7 @@ describe("SSEClientTransport", () => { Connection: "keep-alive", }); res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.write(`data: ${resourceBaseUrl.href}\n\n`); } else { res.writeHead(500); res.end("Internal error"); @@ -197,14 +218,14 @@ describe("SSEClientTransport", () => { }); 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl); + transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { @@ -229,7 +250,7 @@ describe("SSEClientTransport", () => { return fetch(url.toString(), { ...init, headers }); }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { eventSourceInit: { fetch: fetchWithAuth, }, @@ -247,7 +268,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: customHeaders, }, @@ -319,7 +340,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -335,7 +356,7 @@ describe("SSEClientTransport", () => { token_type: "Bearer" }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -355,27 +376,50 @@ describe("SSEClientTransport", () => { }); it("attempts auth flow on 401 during SSE connection", async () => { - // Create server that returns 401s - await server.close(); - server = createServer((req, res) => { + // Create server that returns 401s + resourceServer.close(); + authServer.close(); + + // 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(); + }); + }); + + resourceServer = createServer((req, res) => { lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { - res.writeHead(404).end(); + res.writeHead(404).end(); } else { 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -385,25 +429,45 @@ describe("SSEClientTransport", () => { it("attempts auth flow on 401 during POST request", async () => { // Create server that accepts SSE but returns 401 on POST - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { + 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(); + }); + }); + + resourceServer = createServer((req, res) => { lastServerRequest = req; switch (req.method) { case "GET": + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; } - 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: ${baseUrl.href}\n\n`); + 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`); break; case "POST": @@ -414,14 +478,14 @@ describe("SSEClientTransport", () => { }); 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -448,7 +512,7 @@ describe("SSEClientTransport", () => { "X-Custom-Header": "custom-value", }; - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, requestInit: { headers: customHeaders, @@ -483,11 +547,14 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for expired token, then accepts new token - await server.close(); + resourceServer.close(); + authServer.close(); - let connectionAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request @@ -512,6 +579,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).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(); + }); + }); + + let connectionAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -523,30 +618,30 @@ describe("SSEClientTransport", () => { return; } - if (auth === "Bearer new-token") { - 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: ${baseUrl.href}\n\n`); - connectionAttempts++; - return; - } + if (auth === "Bearer new-token") { + 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`); + connectionAttempts++; + 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -573,12 +668,15 @@ describe("SSEClientTransport", () => { currentTokens = tokens; }); - // Create server that accepts SSE but returns 401 on POST with expired token - await server.close(); + // Create server that returns 401 for expired token, then accepts new token + resourceServer.close(); + authServer.close(); - let postAttempts = 0; - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request @@ -603,6 +701,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).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(); + }); + }); + + let postAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + switch (req.method) { case "GET": if (req.url !== "/") { @@ -610,13 +736,13 @@ describe("SSEClientTransport", () => { return; } - 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: ${baseUrl.href}\n\n`); + 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`); break; case "POST": { @@ -644,14 +770,14 @@ describe("SSEClientTransport", () => { }); 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); @@ -688,10 +814,14 @@ describe("SSEClientTransport", () => { }); // Create server that returns 401 for all tokens - await server.close(); + resourceServer.close(); + authServer.close(); - server = createServer((req, res) => { - lastServerRequest = req; + authServer = createServer((req, res) => { + if (req.url === "/.well-known/oauth-authorization-server") { + res.writeHead(404).end(); + return; + } if (req.url === "/token" && req.method === "POST") { // Handle token refresh request - always fail @@ -699,6 +829,34 @@ describe("SSEClientTransport", () => { return; } + res.writeHead(401).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(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === "/.well-known/oauth-protected-resource") { + res.writeHead(200, { + 'Content-Type': 'application/json', + }) + .end(JSON.stringify({ + resource: "https://resource.example.com", + authorization_servers: [`${authBaseUrl}`], + })); + return; + } + if (req.url !== "/") { res.writeHead(404).end(); return; @@ -707,14 +865,14 @@ describe("SSEClientTransport", () => { }); 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}`); + resourceServer.listen(0, "127.0.0.1", () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); - transport = new SSEClientTransport(baseUrl, { + transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, }); diff --git a/src/client/sse.ts b/src/client/sse.ts index 5e9f0cf0..878a4919 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,7 +1,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; -import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; export class SseError extends Error { constructor( @@ -19,23 +19,23 @@ export class SseError extends Error { export type SSEClientTransportOptions = { /** * An OAuth client provider to use for authentication. - * + * * When an `authProvider` is specified and the SSE connection is started: * 1. The connection is attempted with any existing access token from the `authProvider`. * 2. If the access token has expired, the `authProvider` is used to refresh the token. * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * + * * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. - * + * * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * + * * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. */ authProvider?: OAuthClientProvider; /** * Customizes the initial SSE request to the server (the request that begins the stream). - * + * * NOTE: Setting this property will prevent an `Authorization` header from * being automatically attached to the SSE request, if an `authProvider` is * also given. This can be worked around by setting the `Authorization` header @@ -58,6 +58,7 @@ export class SSEClientTransport implements Transport { private _endpoint?: URL; private _abortController?: AbortController; private _url: URL; + private _resourceMetadataUrl?: URL; private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; @@ -71,6 +72,7 @@ export class SSEClientTransport implements Transport { opts?: SSEClientTransportOptions, ) { this._url = url; + this._resourceMetadataUrl = undefined; this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; @@ -83,7 +85,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -193,7 +195,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -225,7 +227,10 @@ export class SSEClientTransport implements Transport { const response = await fetch(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + this._resourceMetadataUrl = extractResourceMetadataUrl(response); + + 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 3462b2ab..1bcfbb2d 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,6 +1,6 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; -import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; // Default reconnection options for StreamableHTTP connections @@ -119,6 +119,7 @@ export type StreamableHTTPClientTransportOptions = { export class StreamableHTTPClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; + private _resourceMetadataUrl?: URL; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; @@ -133,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { opts?: StreamableHTTPClientTransportOptions, ) { this._url = url; + this._resourceMetadataUrl = undefined; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._sessionId = opts?.sessionId; @@ -146,7 +148,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -225,7 +227,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Calculates the next reconnection delay using backoff algorithm - * + * * @param attempt Current reconnection attempt count for the specific stream * @returns Time to wait in milliseconds before next reconnection attempt */ @@ -242,7 +244,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Schedule a reconnection attempt with exponential backoff - * + * * @param lastEventId The ID of the last received event for resumability * @param attemptCount Current reconnection attempt count for this specific stream */ @@ -356,7 +358,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -401,7 +403,10 @@ export class StreamableHTTPClientTransport implements Transport { if (!response.ok) { if (response.status === 401 && this._authProvider) { - const result = await auth(this._authProvider, { serverUrl: this._url }); + + this._resourceMetadataUrl = extractResourceMetadataUrl(response); + + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } @@ -470,12 +475,12 @@ export class StreamableHTTPClientTransport implements Transport { /** * Terminates the current session by sending a DELETE request to the server. - * + * * Clients that no longer need a particular session * (e.g., because the user is leaving the client application) SHOULD send an * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly * terminate the session. - * + * * The server MAY respond with HTTP 405 Method Not Allowed, indicating that * the server does not allow clients to terminate sessions. */ diff --git a/src/shared/auth.ts b/src/shared/auth.ts index d28cfa9d..65b800e7 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,5 +1,27 @@ import { z } from "zod"; +/** + * RFC 9728 OAuth Protected Resource Metadata + */ +export const OAuthProtectedResourceMetadataSchema = z + .object({ + resource: z.string().url(), + authorization_servers: z.array(z.string().url()).optional(), + jwks_uri: z.string().url().optional(), + scopes_supported: z.array(z.string()).optional(), + bearer_methods_supported: z.array(z.string()).optional(), + resource_signing_alg_values_supported: z.array(z.string()).optional(), + resource_name: z.string().optional(), + resource_documentation: z.string().optional(), + resource_policy_uri: z.string().url().optional(), + resource_tos_uri: z.string().url().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + dpop_bound_access_tokens_required: z.boolean().optional(), + }) + .passthrough(); + /** * RFC 8414 OAuth 2.0 Authorization Server Metadata */ @@ -109,43 +131,6 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); -/** - * RFC 9728 OAuth Protected Resource Metadata - */ - export const OAuthProtectedResourceMetadataSchema = z.object({ - // REQUIRED fields - resource: z.string().url(), - - // OPTIONAL fields - authorization_servers: z.array(z.string().url()).optional(), - - jwks_uri: z.string().url().optional(), - - scopes_supported: z.array(z.string()).optional(), - - bearer_methods_supported: z.array(z.string()).optional(), - - resource_signing_alg_values_supported: z.array(z.string()).optional(), - - resource_name: z.string().optional(), - - resource_documentation: z.string().url().optional(), - - resource_policy_uri: z.string().url().optional(), - - resource_tos_uri: z.string().url().optional(), - - tls_client_certificate_bound_access_tokens: z.boolean().optional(), - - authorization_details_types_supported: z.array(z.string()).optional(), - - dpop_signing_alg_values_supported: z.array(z.string()).optional(), - - dpop_bound_access_tokens_required: z.boolean().optional(), - - // Signed metadata JWT - signed_metadata: z.string().optional() - }).strict(); export type OAuthMetadata = z.infer; export type OAuthTokens = z.infer; From 8fd635cf72bc34104fb12ae60a484e3493069f4a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 21 May 2025 16:29:41 +0100 Subject: [PATCH 050/194] exaple --- src/examples/README.md | 6 + src/examples/client/simpleOAuthClient.ts | 432 +++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 src/examples/client/simpleOAuthClient.ts diff --git a/src/examples/README.md b/src/examples/README.md index e5654b9d..68e1ece2 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -33,6 +33,12 @@ A full-featured interactive client that connects to a Streamable HTTP server, de npx tsx src/examples/client/simpleStreamableHttp.ts ``` +Example client with OAuth: + +```bash +npx tsx src/examples/client/simpleOAuthClient.js +``` + ### Backwards Compatible Client A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts new file mode 100644 index 00000000..75f5653a --- /dev/null +++ b/src/examples/client/simpleOAuthClient.ts @@ -0,0 +1,432 @@ +#!/usr/bin/env node + +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { URL } from 'node:url'; +import { exec } from 'node:child_process'; +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { + CallToolRequest, + ListToolsRequest, + CallToolResultSchema, + ListToolsResultSchema +} from '../../types.js'; +import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js'; + +// Configuration +const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const CALLBACK_PORT = 8090; // Use different port than auth server (3001) +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + +/** + * In-memory OAuth client provider for demonstration purposes + * In production, you should persist tokens securely + */ +class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + onRedirect?: (url: URL) => void + ) { + this._onRedirect = onRedirect || ((url) => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} +/** + * Interactive MCP client with OAuth authentication + * Demonstrates the complete OAuth flow with browser-based authorization + */ +class InteractiveOAuthClient { + private client: Client | null = null; + private readonly rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + constructor(private serverUrl: string) { } + + /** + * Prompts user for input via readline + */ + private async question(query: string): Promise { + return new Promise((resolve) => { + this.rl.question(query, resolve); + }); + } + + /** + * Opens the authorization URL in the user's default browser + */ + private async openBrowser(url: string): Promise { + console.log(`🌐 Opening browser for authorization: ${url}`); + + // Use platform-specific commands to open browser + const platform = process.platform; + let command: string; + + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "" "${url}"`; + } else { + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + console.log(`Please manually open: ${url}`); + } + }); + } + /** + * Example OAuth callback handler - in production, use a more robust approach + * for handling callbacks and storing tokens + */ + /** + * Starts a temporary HTTP server to receive the OAuth callback + */ + private async waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`📥 Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 3000); + } else if (error) { + console.log(`❌ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`❌ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`); + }); + }); + } + + private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { + console.log('🚢 Creating transport with OAuth provider...'); + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('🔌 Attempting connection (this will trigger OAuth redirect)...'); + await this.client!.connect(transport); + console.log('✅ Connected successfully'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('🔐 OAuth required - waiting for authorization...'); + const callbackPromise = this.waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + await this.attemptConnection(oauthProvider); + } else { + console.error('❌ Connection failed with non-auth error:', error); + throw error; + } + } + } + + /** + * Establishes connection to the MCP server with OAuth authentication + */ + async connect(): Promise { + console.log(`🔗 Attempting to connect to ${this.serverUrl}...`); + + const clientMetadata: OAuthClientMetadata = { + client_name: 'Simple OAuth MCP Client', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools' + }; + + console.log('🔐 Creating OAuth provider...'); + const oauthProvider = new InMemoryOAuthClientProvider( + CALLBACK_URL, + clientMetadata, + (redirectUrl: URL) => { + console.log(`📌 OAuth redirect handler called - opening browser`); + console.log(`Opening browser to: ${redirectUrl.toString()}`); + this.openBrowser(redirectUrl.toString()); + } + ); + console.log('🔐 OAuth provider created'); + + console.log('👤 Creating MCP client...'); + this.client = new Client({ + name: 'simple-oauth-client', + version: '1.0.0', + }, { capabilities: {} }); + console.log('👤 Client created'); + + console.log('🔐 Starting OAuth flow...'); + + await this.attemptConnection(oauthProvider); + + // Start interactive loop + await this.interactiveLoop(); + } + + /** + * Main interactive loop for user commands + */ + async interactiveLoop(): Promise { + console.log('\n🎯 Interactive MCP Client with OAuth'); + console.log('Commands:'); + console.log(' list - List available tools'); + console.log(' call [args] - Call a tool'); + console.log(' quit - Exit the client'); + console.log(); + + while (true) { + try { + const command = await this.question('mcp> '); + + if (!command.trim()) { + continue; + } + + if (command === 'quit') { + break; + } else if (command === 'list') { + await this.listTools(); + } else if (command.startsWith('call ')) { + await this.handleCallTool(command); + } else { + console.log('❌ Unknown command. Try \'list\', \'call \', or \'quit\''); + } + } catch (error) { + if (error instanceof Error && error.message === 'SIGINT') { + console.log('\n\n👋 Goodbye!'); + break; + } + console.error('❌ Error:', error); + } + } + } + + private async listTools(): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const request: ListToolsRequest = { + method: 'tools/list', + params: {}, + }; + + const result = await this.client.request(request, ListToolsResultSchema); + + if (result.tools && result.tools.length > 0) { + console.log('\n📋 Available tools:'); + result.tools.forEach((tool, index) => { + console.log(`${index + 1}. ${tool.name}`); + if (tool.description) { + console.log(` Description: ${tool.description}`); + } + console.log(); + }); + } else { + console.log('No tools available'); + } + } catch (error) { + console.error('❌ Failed to list tools:', error); + } + } + + private async handleCallTool(command: string): Promise { + const parts = command.split(/\s+/); + const toolName = parts[1]; + + if (!toolName) { + console.log('❌ Please specify a tool name'); + return; + } + + // Parse arguments (simple JSON-like format) + let toolArgs: Record = {}; + if (parts.length > 2) { + const argsString = parts.slice(2).join(' '); + try { + toolArgs = JSON.parse(argsString); + } catch { + console.log('❌ Invalid arguments format (expected JSON)'); + return; + } + } + + await this.callTool(toolName, toolArgs); + } + + private async callTool(toolName: string, toolArgs: Record): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArgs, + }, + }; + + const result = await this.client.request(request, CallToolResultSchema); + + console.log(`\n🔧 Tool '${toolName}' result:`); + if (result.content) { + result.content.forEach((content) => { + if (content.type === 'text') { + console.log(content.text); + } else { + console.log(content); + } + }); + } else { + console.log(result); + } + } catch (error) { + console.error(`❌ Failed to call tool '${toolName}':`, error); + } + } + + close(): void { + this.rl.close(); + if (this.client) { + // Note: Client doesn't have a close method in the current implementation + // This would typically close the transport connection + } + } +} + +/** + * Main entry point + */ +async function main(): Promise { + const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL; + + console.log('🚀 Simple MCP OAuth Client'); + console.log(`Connecting to: ${serverUrl}`); + console.log(); + + const client = new InteractiveOAuthClient(serverUrl); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\n\n👋 Goodbye!'); + client.close(); + process.exit(0); + }); + + try { + await client.connect(); + } catch (error) { + console.error('Failed to start client:', error); + process.exit(1); + } finally { + client.close(); + } +} + +// Run if this file is executed directly +main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); +}); \ No newline at end of file From 265ad071f79f4494d6a2d2ff9f474ae1d673cf36 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 21 May 2025 11:43:39 +0100 Subject: [PATCH 051/194] make content required --- .../client/parallelToolCallsClient.ts | 2 +- src/examples/client/simpleStreamableHttp.ts | 8 +-- .../streamableHttpWithSseFallbackClient.ts | 2 +- src/examples/server/mcpServerOutputSchema.ts | 5 +- src/server/mcp.test.ts | 45 +++++++++++----- src/types.ts | 52 ++++--------------- 6 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 83c101b7..3783992d 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -61,7 +61,7 @@ async function main(): Promise { // Log the results from each tool call for (const [caller, result] of Object.entries(toolResults)) { console.log(`\n=== Tool result for ${caller} ===`); - result.content?.forEach((item: { type: string; text?: string; }) => { + result.content.forEach((item: { type: string; text?: string; }) => { if (item.type === 'text') { console.log(` ${item.text}`); } else { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 9a20f03a..0328f0d2 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -264,12 +264,12 @@ async function terminateSession(): Promise { console.log('Terminating session with ID:', transport.sessionId); await transport.terminateSession(); console.log('Session terminated successfully'); - + // Check if sessionId was cleared after termination if (!transport.sessionId) { console.log('Session ID has been cleared'); sessionId = undefined; - + // Also close the transport and clear client objects await transport.close(); console.log('Transport closed after session termination'); @@ -341,7 +341,7 @@ async function callTool(name: string, args: Record): Promise { + result.content.forEach(item => { if (item.type === 'text') { console.log(` ${item.text}`); } else { @@ -456,7 +456,7 @@ async function cleanup(): Promise { console.error('Error terminating session:', error); } } - + // Then close the transport await transport.close(); } catch (error) { diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index aaefc680..7646f0f7 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -173,7 +173,7 @@ async function startNotificationTool(client: Client): Promise { const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); - result.content?.forEach(item => { + result.content.forEach(item => { if (item.type === 'text') { console.log(` ${item.text}`); } else { diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 65230fc7..73042d64 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -20,7 +20,7 @@ server.registerTool( "get_weather", { description: "Get weather information for a city", - inputSchema:{ + inputSchema: { city: z.string().describe("City name"), country: z.string().describe("Country code (e.g., US, UK)") }, @@ -46,10 +46,11 @@ server.registerTool( const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; return { + content: [], structuredContent: { temperature: { celsius: temp_c, - fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 + fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10 }, conditions, humidity: Math.round(Math.random() * 100), diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 1b2f4d4b..620a875f 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -398,7 +398,7 @@ describe("tool()", () => { name: z.string(), value: z.number(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ content: [ { type: "text", @@ -729,7 +729,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", { name: z.string() }, { title: "Test Tool", readOnlyHint: true }, async ({ name }) => ({ @@ -787,7 +787,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", "A tool with everything", { name: z.string() }, { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, @@ -828,8 +828,8 @@ describe("tool()", () => { type: "object", properties: { name: { type: "string" } } }); - expect(result.tools[0].annotations).toEqual({ - title: "Complete Test Tool", + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }); @@ -853,7 +853,7 @@ describe("tool()", () => { }); mcpServer.tool( - "test", + "test", "A tool with everything but empty params", {}, { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }, @@ -894,8 +894,8 @@ describe("tool()", () => { type: "object", properties: {} }); - expect(result.tools[0].annotations).toEqual({ - title: "Complete Test Tool with empty params", + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false }); @@ -1077,9 +1077,9 @@ describe("tool()", () => { input: z.string(), }, outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() }, }, async ({ input }) => ({ @@ -1088,6 +1088,16 @@ describe("tool()", () => { resultType: "structured", timestamp: "2023-01-01T00:00:00Z" }, + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + timestamp: "2023-01-01T00:00:00Z" + }), + }, + ] }) ); @@ -1186,6 +1196,17 @@ describe("tool()", () => { }, }, async ({ input }) => ({ + content: [ + { + type: "text", + text: JSON.stringify({ + processedInput: input, + resultType: "structured", + // Missing required 'timestamp' field + someExtraField: "unexpected" // Extra field not in schema + }), + }, + ], structuredContent: { processedInput: input, resultType: "structured", @@ -2664,7 +2685,7 @@ describe("prompt()", () => { name: z.string(), value: z.string(), }, - callback: async ({name, value}) => ({ + callback: async ({ name, value }) => ({ messages: [ { role: "assistant", diff --git a/src/types.ts b/src/types.ts index bd299c8f..98a51321 100644 --- a/src/types.ts +++ b/src/types.ts @@ -843,13 +843,13 @@ export const ToolSchema = z * If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field. */ outputSchema: z.optional( - z.object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) .passthrough() - ), + ), /** * Optional additional tool information. */ @@ -892,46 +892,21 @@ export const ContentListSchema = z.array( ]), ); -export const CallToolUnstructuredResultSchema = ResultSchema.extend({ +export const CallToolResultSchema = ResultSchema.extend({ /** * A list of content objects that represent the result of the tool call. * * If the Tool does not define an outputSchema, this field MUST be present in the result. + * For backwards compatibility, this field is always present, but it may be empty. */ - content: ContentListSchema, + content: ContentListSchema.default([]), - /** - * Structured output must not be provided in an unstructured tool result. - */ - structuredContent: z.never().optional(), - - /** - * Whether the tool call ended in an error. - * - * If not set, this is assumed to be false (the call was successful). - */ - isError: z.optional(z.boolean()), -}); - -export const CallToolStructuredResultSchema = ResultSchema.extend({ /** * An object containing structured tool output. * * If the Tool defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. */ - structuredContent: z.object({}).passthrough(), - - /** - * A list of content objects that represent the result of the tool call. - * - * If the Tool defines an outputSchema, this field MAY be present in the result. - * - * Tools may use this field to provide compatibility with older clients that - * do not support structured content. - * - * Clients that support structured content should ignore this field. - */ - content: z.optional(ContentListSchema), + structuredContent: z.object({}).passthrough().optional(), /** * Whether the tool call ended in an error. @@ -941,11 +916,6 @@ export const CallToolStructuredResultSchema = ResultSchema.extend({ isError: z.optional(z.boolean()), }); -export const CallToolResultSchema = z.union([ - CallToolUnstructuredResultSchema, - CallToolStructuredResultSchema, -]); - /** * CallToolResultSchema extended with backwards compatibility to protocol version 2024-10-07. */ @@ -1397,8 +1367,6 @@ export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; export type ContentList = Infer; -export type CallToolUnstructuredResult = Infer; -export type CallToolStructuredResult = Infer; export type CallToolResult = Infer; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer; From 1d860bb21734df7e60bea568ac698127f2f0b8d1 Mon Sep 17 00:00:00 2001 From: Shubham Lohar <76687222+shubham-lohar@users.noreply.github.com> Date: Wed, 21 May 2025 21:49:49 +0530 Subject: [PATCH 052/194] fixing ProxyOAuthServerProvider: redirect_uri missing in token request (#519) --- src/server/auth/handlers/token.test.ts | 66 ++++++++++++++++++- src/server/auth/handlers/token.ts | 10 ++- src/server/auth/provider.ts | 7 +- .../auth/providers/proxyProvider.test.ts | 22 +++++++ src/server/auth/providers/proxyProvider.ts | 7 +- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index bf41b5eb..c165fe7f 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -322,7 +322,8 @@ describe('Token Handler', () => { client_secret: 'valid-secret', grant_type: 'authorization_code', code: 'valid_code', - code_verifier: 'any_verifier' + code_verifier: 'any_verifier', + redirect_uri: 'https://example.com/callback' }); expect(response.status).toBe(200); @@ -342,6 +343,69 @@ describe('Token Handler', () => { global.fetch = originalFetch; } }); + + it('passes through redirect_uri 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' + }) + }); + + const proxyProvider = new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: 'https://example.com/authorize', + tokenUrl: 'https://example.com/token' + }, + verifyAccessToken: async (token) => ({ + token, + clientId: 'valid-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }), + getClient: async (clientId) => clientId === 'valid-client' ? validClient : undefined + }); + + const proxyApp = express(); + const options: TokenHandlerOptions = { provider: proxyProvider }; + proxyApp.use('/token', tokenHandler(options)); + + const redirectUri = 'https://example.com/callback'; + const response = await supertest(proxyApp) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'any_verifier', + redirect_uri: redirectUri + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + } finally { + global.fetch = originalFetch; + } + }); }); describe('Refresh token grant', () => { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 28412a01..eadbd751 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -31,6 +31,7 @@ const TokenRequestSchema = z.object({ const AuthorizationCodeGrantSchema = z.object({ code: z.string(), code_verifier: z.string(), + redirect_uri: z.string().optional(), }); const RefreshTokenGrantSchema = z.object({ @@ -88,7 +89,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { code, code_verifier } = parseResult.data; + const { code, code_verifier, redirect_uri } = parseResult.data; const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -102,7 +103,12 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand } // Passes the code_verifier to the provider if PKCE validation didn't occur locally - const tokens = await provider.exchangeAuthorizationCode(client, code, skipLocalPkceValidation ? code_verifier : undefined); + const tokens = await provider.exchangeAuthorizationCode( + client, + code, + skipLocalPkceValidation ? code_verifier : undefined, + redirect_uri + ); res.status(200).json(tokens); break; } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 8a0bf0f1..7815b713 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -36,7 +36,12 @@ export interface OAuthServerProvider { /** * Exchanges an authorization code for an access token. */ - exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise; + exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string + ): Promise; /** * Exchanges a refresh token for an access token. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 6e842ea3..69039c3e 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -142,6 +142,28 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it("includes redirect_uri in token request when provided", async () => { + const redirectUri = "https://example.com/callback"; + const tokens = await provider.exchangeAuthorizationCode( + validClient, + "test-code", + "test-verifier", + redirectUri + ); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/token", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + it("exchanges refresh token for new tokens", async () => { const tokens = await provider.exchangeRefreshToken( validClient, diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index be450305..db7460e5 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -151,7 +151,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeAuthorizationCode( client: OAuthClientInformationFull, authorizationCode: string, - codeVerifier?: string + codeVerifier?: string, + redirectUri?: string ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -167,6 +168,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("code_verifier", codeVerifier); } + if (redirectUri) { + params.append("redirect_uri", redirectUri); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { From 281cf0bf8d30fd544192e08ca2a85da0272d97bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Wed, 21 May 2025 09:29:56 -0700 Subject: [PATCH 053/194] Save existing refresh_token in store if no new refresh_token is returned (#483) As described in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#name-refresh-token-response, server MAY return a new refresh_token. In case it doesn't, current implementation discard initial refresh token if it was still valid. --- src/client/auth.test.ts | 23 +++++++++++++++++++++-- src/client/auth.ts | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index eb01d50d..48be870b 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -506,6 +506,9 @@ describe("OAuth Authorization", () => { access_token: "newaccess123", token_type: "Bearer", expires_in: 3600, + } + const validTokensWithNewRefreshToken = { + ...validTokens, refresh_token: "newrefresh123", }; @@ -520,7 +523,7 @@ describe("OAuth Authorization", () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => validTokens, + json: async () => validTokensWithNewRefreshToken, }); const tokens = await refreshAuthorization("https://auth.example.com", { @@ -528,7 +531,7 @@ describe("OAuth Authorization", () => { refreshToken: "refresh123", }); - expect(tokens).toEqual(validTokens); + expect(tokens).toEqual(validTokensWithNewRefreshToken); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ href: "https://auth.example.com/token", @@ -548,6 +551,22 @@ describe("OAuth Authorization", () => { expect(body.get("client_secret")).toBe("secret123"); }); + it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const refreshToken = "refresh123"; + const tokens = await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken, + }); + + expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); + }); + it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/src/client/auth.ts b/src/client/auth.ts index b96d6b0e..16f0a550 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -474,7 +474,7 @@ export async function refreshAuthorization( throw new Error(`Token refresh failed: HTTP ${response.status}`); } - return OAuthTokensSchema.parse(await response.json()); + return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); } /** From 7636c63f393ae0674d2499e3e484a378e032e906 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 12:19:03 -0400 Subject: [PATCH 054/194] update to track spec changes --- .../client/testOutputSchemaServers.ts | 89 -------------- src/examples/server/lowLevelOutputSchema.ts | 116 ------------------ src/examples/server/mcpServerOutputSchema.ts | 31 +++-- src/server/mcp.test.ts | 7 +- src/server/mcp.ts | 41 ++----- src/types.ts | 44 +++---- 6 files changed, 47 insertions(+), 281 deletions(-) delete mode 100755 src/examples/client/testOutputSchemaServers.ts delete mode 100644 src/examples/server/lowLevelOutputSchema.ts diff --git a/src/examples/client/testOutputSchemaServers.ts b/src/examples/client/testOutputSchemaServers.ts deleted file mode 100755 index 5542ec36..00000000 --- a/src/examples/client/testOutputSchemaServers.ts +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -/** - * Client to test outputSchema servers - * This client connects to either the high-level or low-level outputSchema server - * and calls the get_weather tool to demonstrate structured output - */ - -import { Client } from "../../client/index.js"; -import { StdioClientTransport } from "../../client/stdio.js"; - -async function main() { - const serverPath = process.argv[2]; - - if (!serverPath) { - console.error("Usage: npx tsx src/examples/client/testOutputSchemaServers.ts "); - console.error("Example: npx tsx src/examples/client/testOutputSchemaServers.ts ../server/mcpServerOutputSchema.ts"); - process.exit(1); - } - - console.log(`Connecting to ${serverPath}...`); - - // Create transport that spawns the server process - const transport = new StdioClientTransport({ - command: "npx", - args: ["tsx", serverPath] - }); - - const client = new Client({ - name: "output-schema-test-client", - version: "1.0.0" - }, { - capabilities: {} - }); - - try { - await client.connect(transport); - console.log("Connected to server\n"); - - // List available tools - console.log("Listing available tools..."); - const toolsResult = await client.listTools(); - - console.log("Available tools:"); - for (const tool of toolsResult.tools) { - console.log(`- ${tool.name}: ${tool.description}`); - if (tool.outputSchema) { - console.log(" Has outputSchema: Yes"); - console.log(" Output schema:", JSON.stringify(tool.outputSchema, null, 2)); - } else { - console.log(" Has outputSchema: No"); - } - } - - // Call the weather tool - console.log("\nCalling get_weather tool..."); - // Note: Output schema validation only works when using the high-level Client API - // methods like callTool(). Low-level protocol requests do not validate the response. - const weatherResult = await client.callTool({ - name: "get_weather", - arguments: { - city: "London", - country: "UK" - } - }); - - console.log("\nWeather tool result:"); - if (weatherResult.structuredContent) { - console.log("Structured content:"); - console.log(JSON.stringify(weatherResult.structuredContent, null, 2)); - } - - if (weatherResult.content && Array.isArray(weatherResult.content)) { - console.log("Unstructured content:"); - weatherResult.content.forEach(content => { - if (content.type === "text") { - console.log(content.text); - } - }); - } - - await client.close(); - console.log("\nDisconnected from server"); - } catch (error) { - console.error("Error:", error); - process.exit(1); - } -} - -main(); \ No newline at end of file diff --git a/src/examples/server/lowLevelOutputSchema.ts b/src/examples/server/lowLevelOutputSchema.ts deleted file mode 100644 index dfb28415..00000000 --- a/src/examples/server/lowLevelOutputSchema.ts +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -/** - * Example MCP server demonstrating tool outputSchema support using the low-level Server API - * This server manually handles tool listing and invocation requests to return structured data - * For a simpler high-level API approach, see mcpServerOutputSchema.ts - */ - -import { Server } from "../../server/index.js"; -import { StdioServerTransport } from "../../server/stdio.js"; -import { CallToolRequest, CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "../../types.js"; - -const server = new Server( - { - name: "output-schema-low-level-example", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } -); - -// Tool with structured output -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "get_weather", - description: "Get weather information for a city", - inputSchema: { - type: "object", - properties: { - city: { type: "string", description: "City name" }, - country: { type: "string", description: "Country code (e.g., US, UK)" } - }, - required: ["city", "country"] - }, - outputSchema: { - type: "object", - properties: { - temperature: { - type: "object", - properties: { - celsius: { type: "number" }, - fahrenheit: { type: "number" } - }, - required: ["celsius", "fahrenheit"] - }, - conditions: { - type: "string", - enum: ["sunny", "cloudy", "rainy", "stormy", "snowy"] - }, - humidity: { type: "number", minimum: 0, maximum: 100 }, - wind: { - type: "object", - properties: { - speed_kmh: { type: "number" }, - direction: { type: "string" } - }, - required: ["speed_kmh", "direction"] - } - }, - required: ["temperature", "conditions", "humidity", "wind"] - } - } - ] -})); - -server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { - switch (request.params.name) { - case "get_weather": { - const { city, country } = request.params.arguments as { city: string; country: string }; - - // Parameters are available but not used in this example - void city; - 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)]; - - // Return structured content matching the outputSchema - return { - structuredContent: { - temperature: { - celsius: temp_c, - fahrenheit: Math.round((temp_c * 9/5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] - } - } - }; - } - - default: - throw new McpError( - ErrorCode.MethodNotFound, - `Unknown tool: ${request.params.name}` - ); - } -}); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Low-level Output Schema Example Server running on stdio"); -} - -main().catch((error) => { - console.error("Server error:", error); - process.exit(1); -}); \ No newline at end of file diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 73042d64..75bfe690 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -45,21 +45,26 @@ server.registerTool( const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; const conditions = ["sunny", "cloudy", "rainy", "stormy", "snowy"][Math.floor(Math.random() * 5)]; - return { - content: [], - structuredContent: { - temperature: { - celsius: temp_c, - fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] - } + const structuredContent = { + temperature: { + celsius: temp_c, + fahrenheit: Math.round((temp_c * 9 / 5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Math.floor(Math.random() * 8)] } }; + + return { + content: [{ + type: "text", + text: JSON.stringify(structuredContent, null, 2) + }], + structuredContent + }; } ); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 620a875f..37f0c228 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1224,10 +1224,7 @@ describe("tool()", () => { mcpServer.server.connect(serverTransport), ]); - // First call listTools to cache the outputSchema in the client - await client.listTools(); - - // Call the tool and expect it to throw a validation error + // Call the tool and expect it to throw a server-side validation error await expect( client.callTool({ name: "test", @@ -1235,7 +1232,7 @@ describe("tool()", () => { input: "hello", }, }), - ).rejects.toThrow(/Structured content does not match the tool's output schema/); + ).rejects.toThrow(/Invalid structured content for tool test/); }); /*** diff --git a/src/server/mcp.ts b/src/server/mcp.ts index a5f2d0f1..e68691f4 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -198,40 +198,15 @@ export class McpServer { } } - // Handle structured output and backward compatibility - if (tool.outputSchema) { - // Tool has outputSchema, so result must have structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { + if (tool.outputSchema && result.structuredContent) { + // if the tool has an output schema, validate structured content + const parseResult = await tool.outputSchema.safeParseAsync( + result.structuredContent, + ); + if (!parseResult.success) { throw new McpError( - ErrorCode.InternalError, - `Tool ${request.params.name} has outputSchema but returned no structuredContent`, - ); - } - - // For backward compatibility, if structuredContent is provided but no content, - // automatically serialize the structured content to text - if (result.structuredContent && !result.content) { - result.content = [ - { - type: "text", - text: JSON.stringify(result.structuredContent, null, 2), - }, - ]; - } - } else { - // Tool must have content if no outputSchema - if (!result.content && !result.isError) { - throw new McpError( - ErrorCode.InternalError, - `Tool ${request.params.name} has no outputSchema and must return content`, - ); - } - - // If structuredContent is provided, it's an error - if (result.structuredContent) { - throw new McpError( - ErrorCode.InternalError, - `Tool ${request.params.name} has no outputSchema but returned structuredContent`, + ErrorCode.InvalidParams, + `Invalid structured content for tool ${request.params.name}: ${parseResult.error.message}`, ); } } diff --git a/src/types.ts b/src/types.ts index 98a51321..ae25848e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -837,10 +837,8 @@ export const ToolSchema = z }) .passthrough(), /** - * An optional JSON Schema object defining the structure of the tool's output. - * - * If set, a CallToolResult for this Tool MUST contain a structuredContent field whose contents validate against this schema. - * If not set, a CallToolResult for this Tool MUST NOT contain a structuredContent field and MUST contain a content field. + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. */ outputSchema: z.optional( z.object({ @@ -848,7 +846,7 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), required: z.optional(z.array(z.string())), }) - .passthrough() + .passthrough() ), /** * Optional additional tool information. @@ -873,25 +871,7 @@ export const ListToolsResultSchema = PaginatedResultSchema.extend({ /** * The server's response to a tool call. - * - * Any errors that originate from the tool SHOULD be reported inside the result - * object, with `isError` set to true, _not_ as an MCP protocol-level error - * response. Otherwise, the LLM would not be able to see that an error occurred - * and self-correct. - * - * However, any errors in _finding_ the tool, an error indicating that the - * server does not support tool calls, or any other exceptional conditions, - * should be reported as an MCP error response. */ -export const ContentListSchema = z.array( - z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ]), -); - export const CallToolResultSchema = ResultSchema.extend({ /** * A list of content objects that represent the result of the tool call. @@ -899,7 +879,13 @@ export const CallToolResultSchema = ResultSchema.extend({ * If the Tool does not define an outputSchema, this field MUST be present in the result. * For backwards compatibility, this field is always present, but it may be empty. */ - content: ContentListSchema.default([]), + content: z.array( + z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + EmbeddedResourceSchema, + ])).default([]), /** * An object containing structured tool output. @@ -912,6 +898,15 @@ export const CallToolResultSchema = ResultSchema.extend({ * Whether the tool call ended in an error. * * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. */ isError: z.optional(z.boolean()), }); @@ -1366,7 +1361,6 @@ export type ToolAnnotations = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; -export type ContentList = Infer; export type CallToolResult = Infer; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer; From aeb7b8d741ae0b0ca857a22761c04bb9095793d4 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 15:42:14 -0400 Subject: [PATCH 055/194] server error if tool declares outputSchema but no structuredContent in result --- src/server/mcp.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 9 +++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 37f0c228..49f852d6 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1160,6 +1160,70 @@ describe("tool()", () => { expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should throw error when tool with outputSchema returns no structuredContent", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + // 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}`, + }, + ], + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to throw an error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); + }); + /*** * Test: Schema Validation Failure for Invalid Structured Content */ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index e68691f4..5b864b8b 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -198,7 +198,14 @@ export class McpServer { } } - if (tool.outputSchema && result.structuredContent) { + if (tool.outputSchema) { + if (!result.structuredContent) { + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} has an output schema but no structured content was provided`, + ); + } + // if the tool has an output schema, validate structured content const parseResult = await tool.outputSchema.safeParseAsync( result.structuredContent, From 70f03ee648353898c288651e84304869981ac3dd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 21 May 2025 21:27:42 +0100 Subject: [PATCH 056/194] remove non mac open browser commands --- src/examples/client/simpleOAuthClient.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 75f5653a..8d3759c1 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -112,13 +112,7 @@ class InteractiveOAuthClient { const platform = process.platform; let command: string; - if (platform === 'darwin') { - command = `open "${url}"`; - } else if (platform === 'win32') { - command = `start "" "${url}"`; - } else { - command = `xdg-open "${url}"`; - } + command = `open "${url}"`; exec(command, (error) => { if (error) { From 9bb5ed6dc96b05f94122a4a5fdb08ecd68446b8a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 21 May 2025 21:32:01 +0100 Subject: [PATCH 057/194] lint --- src/examples/client/simpleOAuthClient.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 8d3759c1..4531f4c2 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -108,11 +108,7 @@ class InteractiveOAuthClient { private async openBrowser(url: string): Promise { console.log(`🌐 Opening browser for authorization: ${url}`); - // Use platform-specific commands to open browser - const platform = process.platform; - let command: string; - - command = `open "${url}"`; + const command = `open "${url}"`; exec(command, (error) => { if (error) { From 04cbbfbb0d35d4038ac376effed0544a36985415 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 20 May 2025 15:10:40 +0100 Subject: [PATCH 058/194] elicitation example --- src/client/index.test.ts | 38 +++ src/client/index.ts | 8 + src/examples/client/simpleStreamableHttp.ts | 211 ++++++++++++- src/examples/server/simpleStreamableHttp.ts | 153 +++++++++- src/server/index.test.ts | 313 ++++++++++++++++++++ src/server/index.ts | 52 ++++ src/shared/protocol.test.ts | 2 + src/types.ts | 116 ++++++++ 8 files changed, 891 insertions(+), 2 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index bbfa80fa..abd0c34e 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -14,6 +14,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ErrorCode, } from "../types.js"; @@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); +test("should allow setRequestHandler for declared elicitation capability", () => { + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: "test-model", + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + })); + }).toThrow("Client does not support sampling capability"); +}); + /*** * Test: Type Checking * Test that custom request/notification/result schemas can be used with the Client class. diff --git a/src/client/index.ts b/src/client/index.ts index 98618a17..53ef8c52 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -303,6 +303,14 @@ export class Client< } break; + case "elicitation/create": + if (!this._capabilities.elicitation) { + throw new Error( + `Client does not support elicitation capability (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._capabilities.roots) { throw new Error( diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0328f0d2..63efdd2f 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,7 +14,9 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ElicitRequestSchema, } from '../../types.js'; +import { Ajv } from 'ajv'; // Create readline interface for user input const readline = createInterface({ @@ -54,6 +56,7 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); @@ -114,6 +117,10 @@ function commandLoop(): void { await callMultiGreetTool(args[1] || 'MCP User'); break; + case 'collect-info': + await callCollectInfoTool(args[1] || 'contact'); + break; + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -183,15 +190,212 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client + // Create a new client with elicitation capability client = new Client({ name: 'example-client', version: '1.0.0' + }, { + capabilities: { + elicitation: {}, + }, }); client.onerror = (error) => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); } + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, async (request) => { + console.log('\n🔔 Elicitation Request Received:'); + console.log(`Message: ${request.params.message}`); + console.log('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + const schema = request.params.requestedSchema; + const properties = schema.properties; + const required = schema.required || []; + + // Set up AJV validator for the requested schema + const ajv = new Ajv({ strict: false, validateFormats: true }); + const validate = ajv.compile(schema); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`); + + const content: Record = {}; + let inputCancelled = false; + + // Collect input for each field + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const field = fieldSchema as { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + format?: string; + }; + + const isRequired = required.includes(fieldName); + let prompt = `${field.title || fieldName}`; + + // Add helpful information to the prompt + if (field.description) { + prompt += ` (${field.description})`; + } + if (field.enum) { + prompt += ` [options: ${field.enum.join(', ')}]`; + } + if (field.type === 'number' || field.type === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + prompt += ` [${field.minimum}-${field.maximum}]`; + } else if (field.minimum !== undefined) { + prompt += ` [min: ${field.minimum}]`; + } else if (field.maximum !== undefined) { + prompt += ` [max: ${field.maximum}]`; + } + } + if (field.type === 'string' && field.format) { + prompt += ` [format: ${field.format}]`; + } + if (isRequired) { + prompt += ' *required*'; + } + if (field.default !== undefined) { + prompt += ` [default: ${field.default}]`; + } + + prompt += ': '; + + const answer = await new Promise((resolve) => { + readline.question(prompt, (input) => { + resolve(input.trim()); + }); + }); + + // Check for cancellation + if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') { + inputCancelled = true; + break; + } + + // Parse and validate the input + try { + if (answer === '' && field.default !== undefined) { + content[fieldName] = field.default; + } else if (answer === '' && !isRequired) { + // Skip optional empty fields + continue; + } else if (answer === '') { + throw new Error(`${fieldName} is required`); + } else { + // Parse the value based on type + let parsedValue: unknown; + + if (field.type === 'boolean') { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + } else if (field.type === 'number') { + parsedValue = parseFloat(answer); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid number`); + } + } else if (field.type === 'integer') { + parsedValue = parseInt(answer, 10); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid integer`); + } + } else if (field.enum) { + if (!field.enum.includes(answer)) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } + parsedValue = answer; + } else { + parsedValue = answer; + } + + content[fieldName] = parsedValue; + } + } catch (error) { + console.log(`❌ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return { action: 'cancel' }; + } + + // If we didn't complete all fields due to an error, try again + if (Object.keys(content).length !== Object.keys(properties).filter(name => + required.includes(name) || content[name] !== undefined + ).length) { + if (attempts < maxAttempts) { + console.log('Please try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('❌ Validation errors:'); + validate.errors?.forEach(error => { + console.log(` - ${error.instancePath || 'root'}: ${error.message}`); + }); + + if (attempts < maxAttempts) { + console.log('Please correct the errors and try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + } + } + + // Show the collected data and ask for confirmation + console.log('\n✅ Collected data:'); + console.log(JSON.stringify(content, null, 2)); + + const confirmAnswer = await new Promise((resolve) => { + readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => { + resolve(input.trim().toLowerCase()); + }); + }); + + + if (confirmAnswer === 'yes' || confirmAnswer === 'y') { + return { + action: 'accept', + content, + }; + } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { + return { action: 'cancel' }; + } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return { action: 'decline' }; + } + } + } + + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + }); + transport = new StreamableHTTPClientTransport( new URL(serverUrl), { @@ -362,6 +566,11 @@ async function callMultiGreetTool(name: string): Promise { await callTool('multi-greet', { name }); } +async function callCollectInfoTool(infoType: string): Promise { + console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + await callTool('collect-user-info', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c331192..4ef50446 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; @@ -84,6 +84,157 @@ const getServer = () => { } ); + // Register a tool that demonstrates elicitation (user input collection) + // This creates a closure that captures the server instance + server.tool( + 'collect-user-info', + 'A tool that collects user information through elicitation', + { + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'), + }, + async ({ infoType }): Promise => { + let message: string; + let requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + switch (infoType) { + case 'contact': + message = 'Please provide your contact information'; + requestedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + break; + case 'preferences': + message = 'Please set your preferences'; + requestedSchema = { + type: 'object', + properties: { + theme: { + type: 'string', + title: 'Theme', + description: 'Choose your preferred theme', + enum: ['light', 'dark', 'auto'], + enumNames: ['Light', 'Dark', 'Auto'], + }, + notifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Would you like to receive notifications?', + default: true, + }, + frequency: { + type: 'string', + title: 'Notification Frequency', + description: 'How often would you like notifications?', + enum: ['daily', 'weekly', 'monthly'], + enumNames: ['Daily', 'Weekly', 'Monthly'], + }, + }, + required: ['theme'], + }; + break; + case 'feedback': + message = 'Please provide your feedback'; + requestedSchema = { + type: 'object', + properties: { + rating: { + type: 'integer', + title: 'Rating', + description: 'Rate your experience (1-5)', + minimum: 1, + maximum: 5, + }, + comments: { + type: 'string', + title: 'Comments', + description: 'Additional comments (optional)', + maxLength: 500, + }, + recommend: { + type: 'boolean', + title: 'Would you recommend this?', + description: 'Would you recommend this to others?', + }, + }, + required: ['rating', 'recommend'], + }; + break; + default: + throw new Error(`Unknown info type: ${infoType}`); + } + + try { + // Use the underlying server instance to elicit input from the client + const result = await server.server.elicitInput({ + message, + requestedSchema, + }); + + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`, + }, + ], + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: `No information was collected. User declined to provide ${infoType} information.`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `Information collection was cancelled by the user.`, + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error collecting ${infoType} information: ${error}`, + }, + ], + }; + } + } + ); + + // Register a simple prompt server.prompt( 'greeting-template', diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7c0fbc51..ce54247a 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -10,6 +10,7 @@ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, CreateMessageRequestSchema, + ElicitRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, @@ -267,6 +268,318 @@ test("should respect client capabilities", async () => { await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); }); +test("should respect client elicitation capabilities", 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", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + client.setRequestHandler(ElicitRequestSchema, (params) => ({ + action: "accept", + content: { + username: params.params.message.includes("username") ? "test-user" : undefined, + confirmed: true, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: "Please provide your username", + requestedSchema: { + type: "object", + properties: { + username: { + type: "string", + title: "Username", + description: "Your username", + }, + confirmed: { + type: "boolean", + title: "Confirm", + description: "Please confirm", + default: false, + }, + }, + required: ["username"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10, + }), + ).rejects.toThrow(/^Client does not support/); +}); + +test("should validate elicitation response against requested schema", 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", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with valid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + }); +}); + +test("should reject elicitation response with invalid data", 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", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + email: "", // Invalid - too short + age: -5, // Invalid age + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with invalid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).rejects.toThrow(/does not match requested schema/); +}); + +test("should allow elicitation decline and cancel without validation", 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", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, (request) => { + requestCount++; + if (requestCount === 1) { + return { action: "decline" }; + } else { + return { action: "cancel" }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const schema = { + type: "object" as const, + properties: { + name: { type: "string" as const }, + }, + required: ["name"], + }; + + // Test decline - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "decline", + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "cancel", + }); +}); + test("should respect server notification capabilities", async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..6a819db8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,9 @@ import { ClientCapabilities, CreateMessageRequest, CreateMessageResultSchema, + ElicitRequest, + ElicitResult, + ElicitResultSchema, EmptyResultSchema, Implementation, InitializedNotificationSchema, @@ -18,6 +21,8 @@ import { ListRootsRequest, ListRootsResultSchema, LoggingMessageNotification, + McpError, + ErrorCode, Notification, Request, ResourceUpdatedNotification, @@ -28,6 +33,7 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; +import { Ajv } from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -129,6 +135,14 @@ export class Server< } break; + case "elicitation/create": + if (!this._clientCapabilities?.elicitation) { + throw new Error( + `Client does not support elicitation (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._clientCapabilities?.roots) { throw new Error( @@ -294,6 +308,44 @@ export class Server< ); } + async elicitInput( + params: ElicitRequest["params"], + options?: RequestOptions, + ): Promise { + const result = await this.request( + { method: "elicitation/create", params }, + ElicitResultSchema, + options, + ); + + // Validate the response content against the requested schema if action is "accept" + if (result.action === "accept" && result.content) { + try { + const ajv = new Ajv({ strict: false, validateFormats: true }); + + const validate = ajv.compile(params.requestedSchema); + const isValid = validate(result.content); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`, + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error}`, + ); + } + } + + return result; + } + async listRoots( params?: ListRootsRequest["params"], options?: RequestOptions, diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..ac453b17 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -339,6 +339,7 @@ describe("mergeCapabilities", () => { experimental: { feature: true, }, + elicitation: {}, roots: { newProp: true, }, @@ -347,6 +348,7 @@ describe("mergeCapabilities", () => { const merged = mergeCapabilities(base, additional); expect(merged).toEqual({ sampling: {}, + elicitation: {}, roots: { listChanged: true, newProp: true, diff --git a/src/types.ts b/src/types.ts index ae25848e..e5a544b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -218,6 +218,10 @@ export const ClientCapabilitiesSchema = z * Present if the client supports sampling from an LLM. */ sampling: z.optional(z.object({}).passthrough()), + /** + * Present if the client supports eliciting user input. + */ + elicitation: z.optional(z.object({}).passthrough()), /** * Present if the client supports listing roots. */ @@ -1088,6 +1092,107 @@ export const CreateMessageResultSchema = ResultSchema.extend({ ]), }); +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z + .object({ + type: z.literal("boolean"), + title: z.optional(z.string()), + description: z.optional(z.string()), + default: z.optional(z.boolean()), + }) + .passthrough(); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + minLength: z.optional(z.number()), + maxLength: z.optional(z.number()), + format: z.optional(z.enum(["email", "uri", "date", "date-time"])), + }) + .passthrough(); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z + .object({ + type: z.enum(["number", "integer"]), + title: z.optional(z.string()), + description: z.optional(z.string()), + minimum: z.optional(z.number()), + maximum: z.optional(z.number()), + }) + .passthrough(); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + enum: z.array(z.string()), + enumNames: z.optional(z.array(z.string())), + }) + .passthrough(); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([ + BooleanSchemaSchema, + StringSchemaSchema, + NumberSchemaSchema, + EnumSchemaSchema, +]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user. + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal("elicitation/create"), + params: BaseRequestParamsSchema.extend({ + /** + * The message to present to the user. + */ + message: z.string(), + /** + * The schema for the requested user input. + */ + requestedSchema: z + .object({ + type: z.literal("object"), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + }), +}); + +/** + * The client's response to an elicitation/create request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user's response action. + */ + action: z.enum(["accept", "decline", "cancel"]), + /** + * The collected user input content (only present if action is "accept"). + */ + content: z.optional(z.record(z.string(), z.unknown())), +}); + /* Autocomplete */ /** * A reference to a resource or resource template definition. @@ -1227,6 +1332,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + ElicitResultSchema, ListRootsResultSchema, ]); @@ -1234,6 +1340,7 @@ export const ClientResultSchema = z.union([ export const ServerRequestSchema = z.union([ PingRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ]); @@ -1376,6 +1483,15 @@ export type SamplingMessage = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +/* Elicitation */ +export type BooleanSchema = Infer; +export type StringSchema = Infer; +export type NumberSchema = Infer; +export type EnumSchema = Infer; +export type PrimitiveSchemaDefinition = Infer; +export type ElicitRequest = Infer; +export type ElicitResult = Infer; + /* Autocomplete */ export type ResourceReference = Infer; export type PromptReference = Infer; From a89f950222a4d129437f01731766fdb4ca7347b2 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 17:12:42 -0400 Subject: [PATCH 059/194] post-rebase fixes --- src/examples/client/simpleStreamableHttp.ts | 6 +++--- src/server/index.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63efdd2f..4bcaf94c 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -16,7 +16,7 @@ import { ResourceListChangedNotificationSchema, ElicitRequestSchema, } from '../../types.js'; -import { Ajv } from 'ajv'; +import Ajv from "ajv"; // Create readline interface for user input const readline = createInterface({ @@ -215,7 +215,7 @@ async function connect(url?: string): Promise { const required = schema.required || []; // Set up AJV validator for the requested schema - const ajv = new Ajv({ strict: false, validateFormats: true }); + const ajv = new Ajv(); const validate = ajv.compile(schema); let attempts = 0; @@ -352,7 +352,7 @@ async function connect(url?: string): Promise { if (!isValid) { console.log('❌ Validation errors:'); validate.errors?.forEach(error => { - console.log(` - ${error.instancePath || 'root'}: ${error.message}`); + console.log(` - ${error.dataPath || 'root'}: ${error.message}`); }); if (attempts < maxAttempts) { diff --git a/src/server/index.ts b/src/server/index.ts index 6a819db8..506589d9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,7 +33,8 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; -import { Ajv } from "ajv"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -321,7 +322,7 @@ export class Server< // Validate the response content against the requested schema if action is "accept" if (result.action === "accept" && result.content) { try { - const ajv = new Ajv({ strict: false, validateFormats: true }); + const ajv = new Ajv(); const validate = ajv.compile(params.requestedSchema); const isValid = validate(result.content); From eea1519994f78750b516f527dc135697a69f8eac Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 23:42:00 -0400 Subject: [PATCH 060/194] lint --- src/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index 506589d9..5d482d32 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -34,7 +34,6 @@ import { SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; import Ajv from "ajv"; -import type { ValidateFunction } from "ajv"; export type ServerOptions = ProtocolOptions & { /** From cbe9279206a0fcdfa3bc6361f552ebba6cf24c66 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 22 May 2025 10:38:43 +0100 Subject: [PATCH 061/194] add new tool for testing resumability - leave the rest normal --- src/examples/client/simpleStreamableHttp.ts | 57 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0328f0d2..19d32bbc 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -55,6 +55,7 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); + console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); @@ -121,6 +122,13 @@ function commandLoop(): void { break; } + case 'run-notifications-tool-with-resumability': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 10; + await runNotificationsToolWithResumability(interval, count); + break; + } + case 'list-prompts': await listPrompts(); break; @@ -333,12 +341,7 @@ async function callTool(name: string, args: Record): Promise { - notificationsToolLastEventId = event; - }; - const result = await client.request(request, CallToolResultSchema, { - resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate - }); + const result = await client.request(request, CallToolResultSchema); console.log('Tool result:'); result.content.forEach(item => { @@ -353,6 +356,7 @@ async function callTool(name: string, args: Record): Promise { await callTool('greet', { name }); } @@ -367,6 +371,47 @@ async function startNotifications(interval: number, count: number): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`); + console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`); + + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { interval, count } + } + }; + + const onLastEventIdUpdate = (event: string) => { + notificationsToolLastEventId = event; + console.log(`Updated resumption token: ${event}`); + }; + + const result = await client.request(request, CallToolResultSchema, { + resumptionToken: notificationsToolLastEventId, + onresumptiontoken: onLastEventIdUpdate + }); + + console.log('Tool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error) { + console.log(`Error starting notification stream: ${error}`); + } +} + async function listPrompts(): Promise { if (!client) { console.log('Not connected to server.'); From 0516f9898b297da8c97712ea2cacff098edb2525 Mon Sep 17 00:00:00 2001 From: Jeff Escalante Date: Thu, 22 May 2025 07:55:00 -0400 Subject: [PATCH 062/194] add state param to oauth provider (#529) --- src/client/auth.test.ts | 25 +++++++++++++++++++++++++ src/client/auth.ts | 14 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 48be870b..1b9fb071 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -366,6 +366,31 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.has("scope")).toBe(false); }); + it("includes state parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + state: "foobar", + } + ); + + expect(authorizationUrl.searchParams.get("state")).toBe("foobar"); + }); + + it("excludes state parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("state")).toBe(false); + }); + 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 16f0a550..7a91eb25 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -21,6 +21,11 @@ export interface OAuthClientProvider { */ get clientMetadata(): OAuthClientMetadata; + /** + * Returns a OAuth2 state parameter. + */ + state?(): string | Promise; + /** * Loads information about this OAuth client, as registered already with the * server, or returns `undefined` if the client is not registered with the @@ -162,10 +167,13 @@ export async function auth( } } + const state = provider.state ? await provider.state() : undefined; + // Start new authorization flow const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, + state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, }); @@ -301,11 +309,13 @@ export async function startAuthorization( clientInformation, redirectUrl, scope, + state, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; + state?: string; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -347,6 +357,10 @@ export async function startAuthorization( ); authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl)); + if (state) { + authorizationUrl.searchParams.set("state", state); + } + if (scope) { authorizationUrl.searchParams.set("scope", scope); } From acefd57b742379e54429ddf501fa969ec2b67de1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 22 May 2025 16:00:28 +0100 Subject: [PATCH 063/194] bum package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c33cad94..467800fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From d38a5979ac8c8003aad74fb8ddbee551d3b46cd4 Mon Sep 17 00:00:00 2001 From: Daniel Leal Date: Thu, 22 May 2025 19:11:23 +0100 Subject: [PATCH 064/194] Add request init headers defined on the sse transport to the start --- src/client/sse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 878a4919..7939e8cb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -99,11 +99,11 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit = { ...this._requestInit?.headers }; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { - headers["Authorization"] = `Bearer ${tokens.access_token}`; + (headers as Record)["Authorization"] = `Bearer ${tokens.access_token}`; } } From eefdcf5bf3f9e7228966c4978dd46f09b13ac39f Mon Sep 17 00:00:00 2001 From: dhodun Date: Fri, 23 May 2025 20:01:38 -0500 Subject: [PATCH 065/194] docs: Add clarifying comments for stateless streamable HTTP endpoints Add inline comments explaining why GET and DELETE endpoints return 405 in stateless mode: - GET: SSE notifications not supported without session management - DELETE: Session termination not needed in stateless mode --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f468969b..0c954c41 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,7 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// SSE notifications not supported in stateless mode app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET MCP request'); res.writeHead(405).end(JSON.stringify({ @@ -359,6 +360,7 @@ app.get('/mcp', async (req: Request, res: Response) => { })); }); +// Session termination not needed in stateless mode app.delete('/mcp', async (req: Request, res: Response) => { console.log('Received DELETE MCP request'); res.writeHead(405).end(JSON.stringify({ From 5f5180c4d32f1c53cd93f036f5ce3632cc064e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 17:20:02 -0700 Subject: [PATCH 066/194] Register completions capabilities --- src/server/mcp.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..38c869c7 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -236,6 +236,10 @@ export class McpServer { CompleteRequestSchema.shape.method.value, ); + this.server.registerCapabilities({ + completions: {}, + }); + this.server.setRequestHandler( CompleteRequestSchema, async (request): Promise => { From 269c8caf8ec7a248693bc54f22e013ac6259fc8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 18:00:28 -0700 Subject: [PATCH 067/194] Remove server-specific capabilities definition from Client constructor in mcp tests --- src/server/mcp.test.ts | 271 +++++++++++------------------------------ 1 file changed, 72 insertions(+), 199 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..bef8ae02 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -913,18 +913,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 +1048,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 +1154,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 +1211,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 +1279,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 +1328,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 +1380,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 +1430,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 +1489,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 +1534,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: [ @@ -2401,17 +2330,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2469,17 +2391,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 +2455,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 +2960,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 +3159,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: [ @@ -3312,17 +3206,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3380,17 +3267,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 +3330,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) => { From 7e0521550b3bf21a5fe043d7ee62aacc0f8a8c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 19:07:26 -0700 Subject: [PATCH 068/194] Add tests for completions capability registration on template resource with completion or prompt completable argument registration --- src/server/mcp.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index bef8ae02..0ba1998d 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -2321,6 +2321,48 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a resource template with a complete callback is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{category}", { + list: undefined, + complete: { + category: () => ["books", "movies", "music"], + }, + }), + async () => ({ + contents: [ + { + uri: "test://resource/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Resource Template Parameter Completion */ @@ -3197,6 +3239,49 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a prompt with a completable argument is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Prompt Argument Completion */ From 49777d565265424726af6b653adc99b592dbfd9b Mon Sep 17 00:00:00 2001 From: PapacyDai Date: Thu, 29 May 2025 15:15:52 +0800 Subject: [PATCH 069/194] fix: Ensure _meta object is not lost when onprogress option is passed When the onprogress option is passed, the _meta object in the request params was being overwritten, causing any existing _meta content to be lost. --- 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 4694929d..98f8e1c9 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -541,7 +541,10 @@ export abstract class Protocol< this._progressHandlers.set(messageId, options.onprogress); jsonrpcRequest.params = { ...request.params, - _meta: { progressToken: messageId }, + _meta: { + ...(request.params._meta || {}), + progressToken: messageId + }, }; } From cdcb00975cbcfbec6b3fdaa2a5cedbff048332a3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 29 May 2025 14:59:47 +0100 Subject: [PATCH 070/194] fix build and add tests --- src/shared/protocol.test.ts | 126 ++++++++++++++++++++++++++++++++++++ src/shared/protocol.ts | 2 +- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..05bc8f3b 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -27,9 +27,11 @@ class MockTransport implements Transport { describe("protocol tests", () => { let protocol: Protocol; let transport: MockTransport; + let sendSpy: jest.SpyInstance; beforeEach(() => { transport = new MockTransport(); + sendSpy = jest.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -63,6 +65,130 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + describe("_meta preservation with onprogress", () => { + test("should preserve existing _meta when adding progressToken", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should create _meta with progressToken when no _meta exists", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test" + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should not modify _meta when onprogress is not provided", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + + protocol.request(request, mockSchema); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should handle params being undefined with onprogress", async () => { + await protocol.connect(transport); + const request = { + method: "example" + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + }); + describe("progress notification timeout behavior", () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 98f8e1c9..a04f26eb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -542,7 +542,7 @@ export abstract class Protocol< jsonrpcRequest.params = { ...request.params, _meta: { - ...(request.params._meta || {}), + ...(request.params?._meta || {}), progressToken: messageId }, }; From d46d4882e6168ec242d7355f7e50f511f6453172 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 29 May 2025 15:39:24 +0100 Subject: [PATCH 071/194] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 467800fc..fb1dd3cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", + "version": "1.12.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From adbacc63881908031914a527b44ab820523e7f57 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:32:26 -0700 Subject: [PATCH 072/194] Add DNS rebinding protection for SSE transport --- package-lock.json | 4 +- src/server/sse.test.ts | 240 ++++++++++++++++++++++++++++++++++++++++- src/server/sse.ts | 66 +++++++++++- 3 files changed, 306 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40bad9fe..ef539382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..38ba9e59 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,12 +1,14 @@ import http from 'http'; import { jest } from '@jest/globals'; -import { SSEServerTransport } from './sse.js'; +import { SSEServerTransport } from './sse.js'; +import { AuthInfo } from './auth/types.js'; const createMockResponse = () => { const res = { writeHead: jest.fn(), write: jest.fn().mockReturnValue(true), on: jest.fn(), + end: jest.fn().mockReturnThis(), }; res.writeHead.mockReturnThis(); res.on.mockReturnThis(); @@ -14,6 +16,12 @@ const createMockResponse = () => { return res as unknown as http.ServerResponse; }; +const createMockRequest = (headers: Record = {}) => { + return { + headers, + } as unknown as http.IncomingMessage & { auth?: AuthInfo }; +}; + describe('SSEServerTransport', () => { describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { @@ -106,4 +114,234 @@ describe('SSEServerTransport', () => { ); }); }); + + describe('DNS rebinding protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000', 'example.com'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + }); + + it('should reject requests without host header when allowedHosts is configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); + }); + }); + + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with disallowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000'], + }); + await transport.start(); + + const mockReq = createMockRequest({ + origin: 'http://evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + }); + }); + + describe('Content-Type validation', () => { + it('should accept requests with application/json content-type', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'application/json; charset=utf-8', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should reject requests with non-application/json content-type when protection is enabled', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + 'content-type': 'text/plain', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Content-Type must start with application/json, got: text/plain'); + }); + }); + + describe('disableDnsRebindingProtection option', () => { + it('should skip all validations when disableDnsRebindingProtection is true', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: true, + }); + await transport.start(); + + const mockReq = createMockRequest({ + host: 'evil.com', + origin: 'http://evil.com', + 'content-type': 'text/plain', + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + // Should pass even with invalid headers because protection is disabled + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + // The error should be from content-type parsing, not DNS rebinding protection + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); + }); + }); + + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000'], + }); + await transport.start(); + + // Valid host, invalid origin + const mockReq1 = createMockRequest({ + host: 'localhost:3000', + origin: 'http://evil.com', + 'content-type': 'application/json', + }); + const mockHandleRes1 = createMockResponse(); + + await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + + // Invalid host, valid origin + const mockReq2 = createMockRequest({ + host: 'evil.com', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes2 = createMockResponse(); + + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + + // Both valid + const mockReq3 = createMockRequest({ + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + }); + const mockHandleRes3 = createMockResponse(); + + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); + }); + }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..6ef2baef 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -9,6 +9,29 @@ import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; +/** + * Configuration options for SSEServerTransport. + */ +export interface SSEServerTransportOptions { + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Default is false. + */ + disableDnsRebindingProtection?: boolean; +} + /** * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. * @@ -17,6 +40,7 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; + private _options: SSEServerTransportOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -28,8 +52,39 @@ export class SSEServerTransport implements Transport { constructor( private _endpoint: string, private res: ServerResponse, + options?: SSEServerTransportOptions, ) { this._sessionId = randomUUID(); + this._options = options || {disableDnsRebindingProtection: true}; + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is disabled + if (this._options.disableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; } /** @@ -86,13 +141,22 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(validationError); + this.onerror?.(new Error(validationError)); + return; + } + const authInfo: AuthInfo | undefined = req.auth; let body: string | unknown; try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From ebf2535b90ce4c81553f237f49450b6ea4f9ce71 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:52:30 -0700 Subject: [PATCH 073/194] Add protections for streamable HTTP too --- README.md | 22 ++- src/server/streamableHttp.test.ts | 263 +++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 68 ++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c9e27c27..ccc627b2 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,11 @@ app.post('/mcp', async (req, res) => { onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = transport; - } + }, + // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server + // locally, make sure to set: + // disableDnsRebindingProtection: true, + // allowedHosts: ['127.0.0.1'], }); // Clean up transport when closed @@ -386,6 +390,22 @@ This stateless approach is useful for: - RESTful scenarios where each request is independent - Horizontally scaled deployments without shared session state +#### DNS Rebinding Protection + +The Streamable HTTP transport includes DNS rebinding protection to prevent security vulnerabilities. By default, this protection is **disabled** for backwards compatibility. + +**Important**: If you are running this server locally, enable DNS rebinding protection: + +```typescript +const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + disableDnsRebindingProtection: false, + + allowedHosts: ['127.0.0.1', ...], + allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] +}); +``` + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..68fe8ee7 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1293,4 +1293,265 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); -}); \ No newline at end of file +}); + +// Test DNS rebinding protection +describe("StreamableHTTPServerTransport DNS rebinding protection", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe("Host header validation", () => { + it("should accept requests with allowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed host headers", async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain("Invalid Host header:"); + }); + + it("should reject GET requests with disallowed host headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + }, + }); + + expect(response.status).toBe(403); + }); + }); + + describe("Origin header validation", () => { + it("should accept requests with allowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3000", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with disallowed origin headers", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toBe("Invalid Origin header: http://evil.com"); + }); + }); + + describe("disableDnsRebindingProtection option", () => { + it("should skip all validations when disableDnsRebindingProtection is true", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + allowedOrigins: ['http://localhost:3000'], + disableDnsRebindingProtection: true, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Host: "evil.com", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); + }); + }); + + describe("Combined validations", () => { + it("should validate both host and origin when both are configured", async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost:3001'], + allowedOrigins: ['http://localhost:3001'], + disableDnsRebindingProtection: false, + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://evil.com", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + expect(body1.error.message).toBe("Invalid Origin header: http://evil.com"); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Origin: "http://localhost:3001", + }, + body: JSON.stringify(TEST_MESSAGES.initialize), + }); + + expect(response2.status).toBe(200); + }); + }); +}); + +/** + * Helper to create test server with DNS rebinding protection options + */ +async function createTestServerWithDnsProtection(config: { + sessionIdGenerator: (() => string) | undefined; + allowedHosts?: string[]; + allowedOrigins?: string[]; + disableDnsRebindingProtection?: boolean; +}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + allowedHosts: config.allowedHosts, + allowedOrigins: config.allowedOrigins, + disableDnsRebindingProtection: config.disableDnsRebindingProtection, + }); + + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + if (req.method === "POST") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", async () => { + const parsedBody = JSON.parse(body); + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); + }); + } else { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + } + }); + + await new Promise((resolve) => { + httpServer.listen(3001, () => resolve()); + }); + + const port = (httpServer.address() as AddressInfo).port; + const serverUrl = new URL(`http://localhost:${port}/`); + + return { + server: httpServer, + transport, + mcpServer, + baseUrl: serverUrl, + }; +} \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..8feeac55 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -61,6 +61,24 @@ export interface StreamableHTTPServerTransportOptions { * If provided, resumability will be enabled, allowing clients to reconnect and resume messages */ eventStore?: EventStore; + + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + */ + allowedOrigins?: string[]; + + /** + * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Default is true for backwards compatibility. + */ + disableDnsRebindingProtection?: boolean; } /** @@ -109,6 +127,9 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _allowedHosts?: string[]; + private _allowedOrigins?: string[]; + private _disableDnsRebindingProtection: boolean; sessionId?: string | undefined; onclose?: () => void; @@ -120,6 +141,9 @@ export class StreamableHTTPServerTransport implements Transport { this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._allowedHosts = options.allowedHosts; + this._allowedOrigins = options.allowedOrigins; + this._disableDnsRebindingProtection = options.disableDnsRebindingProtection ?? true; } /** @@ -133,10 +157,54 @@ export class StreamableHTTPServerTransport implements Transport { this._started = true; } + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is disabled + if (this._disableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._allowedHosts && this._allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._allowedOrigins && this._allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (!originHeader || !this._allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; + } + /** * Handles an incoming HTTP request, whether GET or POST */ async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: validationError + }, + id: null + })); + this.onerror?.(new Error(validationError)); + return; + } + if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "GET") { From 41c7ed09954d63151c8f96ec8af96a827c399edf Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:53:31 -0700 Subject: [PATCH 074/194] Revert package-lock change --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef539382..40bad9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.11.4", "license": "MIT", "dependencies": { "ajv": "^6.12.6", From 88c6098495522fc34d9554076998363862617df0 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 13:54:49 -0700 Subject: [PATCH 075/194] Clean up --- src/server/sse.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 6ef2baef..65e8c7c8 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -27,7 +27,6 @@ export interface SSEServerTransportOptions { /** * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). - * Default is false. */ disableDnsRebindingProtection?: boolean; } @@ -156,7 +155,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct.type}`); + throw new Error(`Unsupported content-type: ${ct}`); } body = parsedBody ?? await getRawBody(req, { From 970905c3131276d1f6b392f060faac91bddbf3fa Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 29 May 2025 14:01:36 -0700 Subject: [PATCH 076/194] Fix SSE content-type error message format --- src/server/sse.test.ts | 2 +- src/server/sse.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 38ba9e59..9fb1f30c 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -264,7 +264,7 @@ describe('SSEServerTransport', () => { await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Content-Type must start with application/json, got: text/plain'); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 65e8c7c8..73c74bb3 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -155,7 +155,7 @@ export class SSEServerTransport implements Transport { try { const ct = contentType.parse(req.headers["content-type"] ?? ""); if (ct.type !== "application/json") { - throw new Error(`Unsupported content-type: ${ct}`); + throw new Error(`Unsupported content-type: ${ct.type}`); } body = parsedBody ?? await getRawBody(req, { From be063e4bb8e4dcad3e7698a627f6efc9d7e4c065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Garc=C3=ADa?= Date: Fri, 30 May 2025 16:05:05 +0200 Subject: [PATCH 077/194] fix: extra Headers when they are a Headers object --- src/client/streamableHttp.test.ts | 31 +++++++++++++++++++++++++++++++ src/client/streamableHttp.ts | 23 ++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index f748a2be..11dfe7d4 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -476,6 +476,37 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + it("should always send specified custom headers (Headers class)", async () => { + const requestInit = { + headers: new Headers({ + "X-Custom-Header": "CustomValue" + }) + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + ((global.fetch as jest.Mock)).mockImplementation( + async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + ); + + await transport.start(); + + await transport["_startOrAuthSse"]({}); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); + + (requestInit.headers as Headers).set("X-Custom-Header","SecondCustomValue"); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue"); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); it("should have exponential backoff with configurable maxRetries", () => { // This test verifies the maxRetries and backoff calculation directly diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bcfbb2d..2bb0fdf4 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -174,9 +174,12 @@ export class StreamableHTTPClientTransport implements Transport { headers["mcp-session-id"] = this._sessionId; } - return new Headers( - { ...headers, ...this._requestInit?.headers } - ); + const extraHeaders = this._normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders, + }); } @@ -242,6 +245,20 @@ export class StreamableHTTPClientTransport implements Transport { } + private _normalizeHeaders(headers: HeadersInit | undefined): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return { ...headers as Record }; + } + /** * Schedule a reconnection attempt with exponential backoff * From ab900839fbd450fbd86a1fb0034803a892048c29 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Fri, 30 May 2025 09:06:01 -0700 Subject: [PATCH 078/194] Invert variable to improve code readability --- README.md | 4 ++-- src/server/sse.test.ts | 12 +++++++++--- src/server/sse.ts | 11 ++++++----- src/server/streamableHttp.test.ts | 22 +++++++++++----------- src/server/streamableHttp.ts | 14 +++++++------- 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ccc627b2..32037f90 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ app.post('/mcp', async (req, res) => { }, // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server // locally, make sure to set: - // disableDnsRebindingProtection: true, + // enableDnsRebindingProtection: true, // allowedHosts: ['127.0.0.1'], }); @@ -399,7 +399,7 @@ The Streamable HTTP transport includes DNS rebinding protection to prevent secur ```typescript const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, allowedHosts: ['127.0.0.1', ...], allowedOrigins: ['https://yourdomain.com', 'https://www.yourdomain.com'] diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 9fb1f30c..aee6eaf6 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -125,6 +125,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000', 'example.com'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -144,6 +145,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -163,6 +165,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -183,6 +186,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -202,6 +206,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); @@ -268,13 +273,13 @@ describe('SSEServerTransport', () => { }); }); - describe('disableDnsRebindingProtection option', () => { - it('should skip all validations when disableDnsRebindingProtection is true', async () => { + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { const mockRes = createMockResponse(); const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: true, + enableDnsRebindingProtection: false, }); await transport.start(); @@ -300,6 +305,7 @@ describe('SSEServerTransport', () => { const transport = new SSEServerTransport('/messages', mockRes, { allowedHosts: ['localhost:3000'], allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true, }); await transport.start(); diff --git a/src/server/sse.ts b/src/server/sse.ts index 73c74bb3..bd5d80b9 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -26,9 +26,10 @@ export interface SSEServerTransportOptions { allowedOrigins?: string[]; /** - * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. */ - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; } /** @@ -54,7 +55,7 @@ export class SSEServerTransport implements Transport { options?: SSEServerTransportOptions, ) { this._sessionId = randomUUID(); - this._options = options || {disableDnsRebindingProtection: true}; + this._options = options || {enableDnsRebindingProtection: false}; } /** @@ -62,8 +63,8 @@ export class SSEServerTransport implements Transport { * @returns Error message if validation fails, undefined if validation passes. */ private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is disabled - if (this._options.disableDnsRebindingProtection) { + // Skip validation if protection is not enabled + if (!this._options.enableDnsRebindingProtection) { return undefined; } diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 68fe8ee7..4683024b 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1312,7 +1312,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1338,7 +1338,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['example.com:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1362,7 +1362,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['example.com:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1384,7 +1384,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedOrigins: ['http://localhost:3000', 'https://example.com'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1407,7 +1407,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1429,13 +1429,13 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { }); }); - describe("disableDnsRebindingProtection option", () => { - it("should skip all validations when disableDnsRebindingProtection is true", async () => { + describe("enableDnsRebindingProtection option", () => { + it("should skip all validations when enableDnsRebindingProtection is false", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], allowedOrigins: ['http://localhost:3000'], - disableDnsRebindingProtection: true, + enableDnsRebindingProtection: false, }); server = result.server; transport = result.transport; @@ -1463,7 +1463,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { sessionIdGenerator: undefined, allowedHosts: ['localhost:3001'], allowedOrigins: ['http://localhost:3001'], - disableDnsRebindingProtection: false, + enableDnsRebindingProtection: true, }); server = result.server; transport = result.transport; @@ -1507,7 +1507,7 @@ async function createTestServerWithDnsProtection(config: { sessionIdGenerator: (() => string) | undefined; allowedHosts?: string[]; allowedOrigins?: string[]; - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; }): Promise<{ server: Server; transport: StreamableHTTPServerTransport; @@ -1523,7 +1523,7 @@ async function createTestServerWithDnsProtection(config: { sessionIdGenerator: config.sessionIdGenerator, allowedHosts: config.allowedHosts, allowedOrigins: config.allowedOrigins, - disableDnsRebindingProtection: config.disableDnsRebindingProtection, + enableDnsRebindingProtection: config.enableDnsRebindingProtection, }); await mcpServer.connect(transport); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 8feeac55..084147dc 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -75,10 +75,10 @@ export interface StreamableHTTPServerTransportOptions { allowedOrigins?: string[]; /** - * Disable DNS rebinding protection entirely (overrides allowedHosts and allowedOrigins). - * Default is true for backwards compatibility. + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. */ - disableDnsRebindingProtection?: boolean; + enableDnsRebindingProtection?: boolean; } /** @@ -129,7 +129,7 @@ export class StreamableHTTPServerTransport implements Transport { private _onsessioninitialized?: (sessionId: string) => void; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; - private _disableDnsRebindingProtection: boolean; + private _enableDnsRebindingProtection: boolean; sessionId?: string | undefined; onclose?: () => void; @@ -143,7 +143,7 @@ export class StreamableHTTPServerTransport implements Transport { this._onsessioninitialized = options.onsessioninitialized; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; - this._disableDnsRebindingProtection = options.disableDnsRebindingProtection ?? true; + this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; } /** @@ -162,8 +162,8 @@ export class StreamableHTTPServerTransport implements Transport { * @returns Error message if validation fails, undefined if validation passes. */ private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is disabled - if (this._disableDnsRebindingProtection) { + // Skip validation if protection is not enabled + if (!this._enableDnsRebindingProtection) { return undefined; } From 6cf034595dbfb58c2061a370a49da4078e429d1e Mon Sep 17 00:00:00 2001 From: Carl Peaslee Date: Fri, 30 May 2025 11:32:10 -0700 Subject: [PATCH 079/194] updates listed resources from resource templates to favor their own metadata --- src/server/mcp.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..65ed1b8f 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -369,8 +369,9 @@ export class McpServer { const result = await template.resourceTemplate.listCallback(extra); for (const resource of result.resources) { templateResources.push({ - ...resource, ...template.metadata, + // the defined resource metadata should override the template metadata if present + ...resource, }); } } From 1878143f1a8ffc3ce5f7acd4dc61cef67b589b4b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 5 Jun 2025 11:52:59 -0400 Subject: [PATCH 080/194] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb1dd3cf..764ce2cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.12.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 921ce70fa3acf1cb626bc7ff5088f358727be335 Mon Sep 17 00:00:00 2001 From: sinedied Date: Mon, 9 Jun 2025 14:17:53 +0200 Subject: [PATCH 081/194] fix: missing "properties" property in empty schema --- src/server/mcp.test.ts | 1 + src/server/mcp.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..fd71443d 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -265,6 +265,7 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].inputSchema).toEqual({ type: "object", + properties: {}, }); // Adding the tool before the connection was established means no notification was sent diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..436a948f 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1018,6 +1018,7 @@ export type RegisteredTool = { const EMPTY_OBJECT_JSON_SCHEMA = { type: "object" as const, + properties: {}, }; // Helper to check if an object is a Zod schema (ZodRawShape) From 2d61b4b8fc0a63162a4b64aa52793af7e9f27d15 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 10 Jun 2025 15:52:20 +0100 Subject: [PATCH 082/194] adding README --- README.md | 102 +++++++++++++++++ src/server/mcp.test.ts | 255 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) diff --git a/README.md b/README.md index c9e27c27..6c100735 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,108 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Eliciting User Input + +MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: + +```typescript +// Server-side: Restaurant booking tool that asks for alternatives +server.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await server.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } +); + +// Client-side: Handle elicitation requests + + +// This is a placeholder - implement based on your UI framework +async function getInputFromUser(message: string, schema: any): Promise<{ + action: "accept" | "decline" | "cancel"; + data?: Record; +}> { + // This should be implemented depending on the app + throw new Error("getInputFromUser must be implemented for your platform"); +} + +client.setRequestHandler(ElicitRequestSchema, async (request) => { + const userResponse = await getInputFromUser( + request.params.message, + request.params.requestedSchema + ); + + return { + action: userResponse.action, + content: userResponse.action === "accept" ? userResponse.data : undefined + }; +}); +``` + +**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. + ### Writing MCP Clients The SDK provides a high-level client interface: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 0ba1998d..bcf09d52 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,6 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, TextContent, + ElicitRequestSchema, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -3457,4 +3458,258 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); + + /** + * Test: Restaurant booking example with elicitation from README + */ + describe("Restaurant booking elicitation example", () => { + // Mock restaurant booking functions + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + } + ); + }); + + test("should successfully book when table is available", async () => { + // Mock availability check to return true + checkAvailability.mockResolvedValue(true); + makeBooking.mockResolvedValue("BOOKING-123"); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(makeBooking).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(result.content).toEqual([{ + type: "text", + text: "Booked table for 2 at ABC Restaurant on 2024-12-25" + }]); + }); + + test("should ask for alternatives when no availability and user accepts", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); + + test("should handle user declining to check alternatives", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to decline alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + }); }); From c08871998530d3f5f10e53d52342c645e2a73230 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 10 Jun 2025 17:26:48 +0100 Subject: [PATCH 083/194] tests --- src/server/mcp.test.ts | 393 +++++++++++++++++++---------------------- 1 file changed, 179 insertions(+), 214 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index bcf09d52..7cb0c489 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3458,258 +3458,223 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); +}); - /** - * Test: Restaurant booking example with elicitation from README - */ - describe("Restaurant booking elicitation example", () => { - // Mock restaurant booking functions - const checkAvailability = jest.fn().mockResolvedValue(false); - const findAlternatives = jest.fn().mockResolvedValue([]); - const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); +describe("elicitInput()", () => { - let mcpServer: McpServer; - let client: Client; + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Create server with restaurant booking tool - mcpServer = new McpServer({ - name: "restaurant-booking-server", - version: "1.0.0", - }); + let mcpServer: McpServer; + let client: Client; - // Register the restaurant booking tool from README example - mcpServer.tool( - "book-restaurant", - { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: "object", - properties: { - checkAlternatives: { - type: "boolean", - title: "Check alternative dates", - description: "Would you like me to check other dates?" - }, - flexibleDates: { - type: "string", - title: "Date flexibility", - description: "How flexible are your dates?", - enum: ["next_day", "same_week", "next_week"], - enumNames: ["Next day", "Same week", "Next week"] - } + beforeEach(() => { + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" }, - required: ["checkAlternatives"] - } - }); - - if (result.action === "accept" && result.content?.checkAlternatives) { - const alternatives = await findAlternatives( - restaurant, - date, - partySize, - result.content.flexibleDates as string - ); - return { - content: [{ - type: "text", - text: `Found these alternatives: ${alternatives.join(", ")}` - }] - }; + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] } - + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); return { content: [{ type: "text", - text: "No booking made. Original date not available." + text: `Found these alternatives: ${alternatives.join(", ")}` }] }; } - - // Book the table - await makeBooking(restaurant, date, partySize); + return { content: [{ type: "text", - text: `Booked table for ${partySize} at ${restaurant} on ${date}` + text: "No booking made. Original date not available." }] }; } - ); - // Create client with elicitation capability - client = new Client( - { - name: "test-client", - version: "1.0.0", + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, }, - { - capabilities: { - elicitation: {}, - }, + } + ); + }); + + test("should successfully elicit additional information", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" } - ); + }; }); - test("should successfully book when table is available", async () => { - // Mock availability check to return true - checkAvailability.mockResolvedValue(true); - makeBooking.mockResolvedValue("BOOKING-123"); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(makeBooking).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(result.content).toEqual([{ - type: "text", - text: "Booked table for 2 at ABC Restaurant on 2024-12-25" - }]); + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } }); - test("should ask for alternatives when no availability and user accepts", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); - // Set up client to accept alternative date checking - client.setRequestHandler(ElicitRequestSchema, async (request) => { - expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); - return { - action: "accept", - content: { - checkAlternatives: true, - flexibleDates: "same_week" - } - }; - }); + test("should handle user declining to elicitation request", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 + // Set up client to decline alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); - expect(result.content).toEqual([{ - type: "text", - text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" - }]); + }; }); - test("should handle user declining to check alternatives", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set up client to decline alternative date checking - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: "accept", - content: { - checkAlternatives: false - } - }; - }); + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([{ - type: "text", - text: "No booking made. Original date not available." - }]); + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } }); - test("should handle user cancelling the elicitation", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); - // Set up client to cancel the elicitation - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: "cancel" - }; - }); + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([{ - type: "text", - text: "No booking made. Original date not available." - }]); + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); }); }); From 84971c83ea19f6481f72e4e489f101c8a2055458 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 10 Jun 2025 14:07:10 +0100 Subject: [PATCH 084/194] Set Mcp-Protocol-Version in client requests after init, and warn when it doesn't match negotiated version (it SHOULD match) --- src/client/index.ts | 2 + src/client/sse.ts | 14 +- src/client/streamableHttp.ts | 6 +- .../stateManagementStreamableHttp.test.ts | 21 +++ src/server/index.ts | 11 +- src/server/sse.ts | 1 + src/server/streamableHttp.test.ts | 145 +++++++++++++++++- src/server/streamableHttp.ts | 55 ++++++- src/shared/transport.ts | 7 + 9 files changed, 243 insertions(+), 19 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 98618a17..0e7d6cf4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -165,6 +165,8 @@ export class Client< this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; + // HTTP transports must set the protocol version in each header after initialization. + transport.protocolVersion = result.protocolVersion; this._instructions = result.instructions; diff --git a/src/client/sse.ts b/src/client/sse.ts index 7939e8cb..a80e06eb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport { private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -99,13 +100,18 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = { ...this._requestInit?.headers }; + const headers = { + ...this._requestInit?.headers, + } as HeadersInit & Record; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { - (headers as Record)["Authorization"] = `Bearer ${tokens.access_token}`; + headers["Authorization"] = `Bearer ${tokens.access_token}`; } } + if (this.protocolVersion) { + headers["mcp-protocol-version"] = this.protocolVersion; + } return headers; } @@ -214,7 +220,9 @@ export class SSEClientTransport implements Transport { try { const commonHeaders = await this._commonHeaders(); - const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + // Note: this._requestInit?.headers already set in _commonHeaders + // const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + const headers = new Headers(commonHeaders); headers.set("content-type", "application/json"); const init = { ...this._requestInit, diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bcfbb2d..1bc2724e 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -162,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -173,6 +174,9 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } + if (this.protocolVersion != null) { + headers["mcp-protocol-version"] = this.protocolVersion; + } return new Headers( { ...headers, ...this._requestInit?.headers } diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index b7ff17e6..503be80a 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => { // Clean up await transport.close(); }); + + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); + + await client.connect(transport); + + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe('2025-03-26'); + + // Clean up + await transport.close(); + }); }); describe('Stateful Mode', () => { diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..d05c5489 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -251,10 +251,15 @@ export class Server< this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - return { - protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion - : LATEST_PROTOCOL_VERSION, + : LATEST_PROTOCOL_VERSION; + if (this.transport) { + this.transport.protocolVersion = protocolVersion; + } + + return { + protocolVersion, capabilities: this.getCapabilities(), serverInfo: this._serverInfo, ...(this._instructions && { instructions: this._instructions }), diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..b2f85ce0 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -18,6 +18,7 @@ export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..51cc4c47 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe if (sessionId) { headers["mcp-session-id"] = sessionId; + // After initialization, include the protocol version header + headers["mcp-protocol-version"] = "2025-03-26"; } return fetch(baseUrl, { @@ -277,7 +279,7 @@ describe("StreamableHTTPServerTransport", () => { expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); }); - it("should pandle post requests via sse response correctly", async () => { + it("should handle post requests via sse response correctly", async () => { sessionId = await initializeServer(); const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); @@ -376,6 +378,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -417,6 +420,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -448,6 +452,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -459,6 +464,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -477,6 +483,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "application/json", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -670,6 +677,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -705,7 +713,10 @@ describe("StreamableHTTPServerTransport", () => { // Now DELETE the session const deleteResponse = await fetch(tempUrl, { method: "DELETE", - headers: { "mcp-session-id": tempSessionId || "" }, + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, }); expect(deleteResponse.status).toBe(200); @@ -721,13 +732,129 @@ describe("StreamableHTTPServerTransport", () => { // Try to delete with invalid session ID const response = await fetch(baseUrl, { method: "DELETE", - headers: { "mcp-session-id": "invalid-session-id" }, + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, }); expect(response.status).toBe(404); const errorData = await response.json(); expectErrorResponse(errorData, -32001, /Session not found/); }); + + describe("protocol version header validation", () => { + it("should accept requests with matching protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + }); + + it("should accept requests without protocol version header", async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with unsupported protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "1999-01-01", // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + + it("should accept but warn 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", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "2024-11-05", // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + // Request should still succeed + expect(response.status).toBe(200); + + // But warning should have been logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Request has header with protocol version 2024-11-05, but version previously negotiated is 2025-03-26") + ); + + warnSpy.mockRestore(); + }); + + it("should handle protocol version validation for GET requests", async () => { + sessionId = await initializeServer(); + + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + + it("should handle protocol version validation for DELETE requests", async () => { + sessionId = await initializeServer(); + + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + }); }); describe("StreamableHTTPServerTransport with AuthInfo", () => { @@ -1120,6 +1247,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -1196,6 +1324,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", "last-event-id": firstEventId }, }); @@ -1282,14 +1411,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open first SSE stream const stream1 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream1.status).toBe(200); // Open second SSE stream - should still be rejected, stateless mode still only allows one const stream2 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..b387f2ff 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -110,7 +110,8 @@ export class StreamableHTTPServerTransport implements Transport { private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; - sessionId?: string | undefined; + sessionId?: string; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; @@ -172,6 +173,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } // Handle resumability: check for Last-Event-ID header if (this._eventStore) { const lastEventId = req.headers['last-event-id'] as string | undefined; @@ -378,11 +382,17 @@ export class StreamableHTTPServerTransport implements Transport { } } - // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it - // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - if (!isInitializationRequest && !this.validateSession(req, res)) { - return; + if (!isInitializationRequest) { + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (!this.validateSession(req, res)) { + return; + } + // Mcp-Protocol-Version header is required for all requests after initialization. + if (!this.validateProtocolVersion(req, res)) { + return; + } } @@ -457,6 +467,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } await this.close(); res.writeHead(200).end(); } @@ -524,6 +537,34 @@ export class StreamableHTTPServerTransport implements Transport { return true; } + private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean { + let protocolVersion = req.headers["mcp-protocol-version"]; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + if (protocolVersion == null || protocolVersion === undefined) { + // If the protocol version is not set, we assume the client supports the implicit protocol version + return true; + } + + protocolVersion = String(protocolVersion).trim(); + if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { + console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); + } + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: 'Bad Request: Unsupported protocol version' + }, + id: null + })); + return false; + } + return true; + } async close(): Promise { // Close all SSE connections diff --git a/src/shared/transport.ts b/src/shared/transport.ts index fe0a60e6..976be79d 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -75,4 +75,11 @@ export interface Transport { * The session ID generated for this connection. */ sessionId?: string; + + /** + * The protocol version used for the connection. + * + * Only set after the initialize response was received. + */ + protocolVersion?: string; } From 255d30cbf2fb070898f3517c88957753ccbb5d2f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 15:10:12 +0100 Subject: [PATCH 085/194] Rename ResourceReference to ResourceTemplateReference --- src/server/mcp.ts | 4 ++-- src/types.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 38c869c7..a20659b9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -21,7 +21,7 @@ import { CompleteRequest, CompleteResult, PromptReference, - ResourceReference, + ResourceTemplateReference, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -297,7 +297,7 @@ export class McpServer { private async handleResourceCompletion( request: CompleteRequest, - ref: ResourceReference, + ref: ResourceTemplateReference, ): Promise { const template = Object.values(this._registeredResourceTemplates).find( (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, diff --git a/src/types.ts b/src/types.ts index ae25848e..9334e4c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1092,7 +1092,7 @@ export const CreateMessageResultSchema = ResultSchema.extend({ /** * A reference to a resource or resource template definition. */ -export const ResourceReferenceSchema = z +export const ResourceTemplateReferenceSchema = z .object({ type: z.literal("ref/resource"), /** @@ -1102,6 +1102,11 @@ export const ResourceReferenceSchema = z }) .passthrough(); +/** + * @deprecated Use ResourceTemplateReferenceSchema instead + */ +export const ResourceReferenceSchema = ResourceTemplateReferenceSchema; + /** * Identifies a prompt. */ @@ -1121,7 +1126,7 @@ export const PromptReferenceSchema = z export const CompleteRequestSchema = RequestSchema.extend({ method: z.literal("completion/complete"), params: BaseRequestParamsSchema.extend({ - ref: z.union([PromptReferenceSchema, ResourceReferenceSchema]), + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), /** * The argument's information */ @@ -1377,7 +1382,11 @@ export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; /* Autocomplete */ -export type ResourceReference = Infer; +export type ResourceTemplateReference = Infer; +/** + * @deprecated Use ResourceTemplateReference instead + */ +export type ResourceReference = ResourceTemplateReference; export type PromptReference = Infer; export type CompleteRequest = Infer; export type CompleteResult = Infer; From bb0592bd50935ca487e3f08568b9e4d7313a7c64 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:42:51 +0100 Subject: [PATCH 086/194] add supported versions to error message --- src/server/streamableHttp.test.ts | 6 +++--- src/server/streamableHttp.ts | 15 +++++---------- src/types.ts | 1 + 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 51cc4c47..f00721e2 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -788,7 +788,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); it("should accept but warn when protocol version differs from negotiated version", async () => { @@ -835,7 +835,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); it("should handle protocol version validation for DELETE requests", async () => { @@ -852,7 +852,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); }); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b387f2ff..c6010fd3 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS } from "../types.js"; +import { 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"; @@ -538,17 +538,12 @@ export class StreamableHTTPServerTransport implements Transport { } private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean { - let protocolVersion = req.headers["mcp-protocol-version"]; + let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; if (Array.isArray(protocolVersion)) { protocolVersion = protocolVersion[protocolVersion.length - 1]; } - - if (protocolVersion == null || protocolVersion === undefined) { - // If the protocol version is not set, we assume the client supports the implicit protocol version - return true; - } - - protocolVersion = String(protocolVersion).trim(); + + if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); } @@ -557,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { jsonrpc: "2.0", error: { code: -32000, - message: 'Bad Request: Unsupported protocol version' + message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})` }, id: null })); diff --git a/src/types.ts b/src/types.ts index ae25848e..4bf87ef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import { z, ZodTypeAny } from "zod"; export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, "2024-11-05", From a28e25a9c0c4e4bd3ee1ead292c30b1238d9d057 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:43:06 +0100 Subject: [PATCH 087/194] use const in test --- src/integration-tests/stateManagementStreamableHttp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 503be80a..d12a4f99 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js'; import { z } from 'zod'; describe('Streamable HTTP Transport Session Management', () => { @@ -227,7 +227,7 @@ describe('Streamable HTTP Transport Session Management', () => { await client.connect(transport); // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe('2025-03-26'); + expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); // Clean up await transport.close(); From e573a0f342f92b24250d860bc0f3d2a6aa55c53e Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:43:49 +0100 Subject: [PATCH 088/194] nits: remove dead comment & align test style on local idioms --- src/client/sse.ts | 2 -- src/client/streamableHttp.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index a80e06eb..3111cd9d 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -220,8 +220,6 @@ export class SSEClientTransport implements Transport { try { const commonHeaders = await this._commonHeaders(); - // Note: this._requestInit?.headers already set in _commonHeaders - // const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); const headers = new Headers(commonHeaders); headers.set("content-type", "application/json"); const init = { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bc2724e..2a1895f5 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -174,7 +174,7 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } - if (this.protocolVersion != null) { + if (this.protocolVersion) { headers["mcp-protocol-version"] = this.protocolVersion; } From bc5312991ebf47d5133b22d04e9a56cb3b9a3f15 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 18:47:16 +0100 Subject: [PATCH 089/194] adding tests to prevent regressions --- src/server/mcp.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..b7bfdffb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3499,4 +3499,144 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); + + /*** + * Test: Resource Template Metadata Priority + */ + test("should prioritize individual resource metadata over template metadata", 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/{id}", { + list: async () => ({ + resources: [ + { + name: "Resource 1", + uri: "test://resource/1", + description: "Individual resource description", + mimeType: "text/plain", + }, + { + name: "Resource 2", + uri: "test://resource/2", + // This resource has no description or mimeType + }, + ], + }), + }), + { + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0].name).toBe("Resource 1"); + expect(result.resources[0].description).toBe("Individual resource description"); + expect(result.resources[0].mimeType).toBe("text/plain"); + + // Resource 2 should inherit template metadata + expect(result.resources[1].name).toBe("Resource 2"); + expect(result.resources[1].description).toBe("Template description"); + expect(result.resources[1].mimeType).toBe("application/json"); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test("should allow resource to override all template metadata fields", 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/{id}", { + list: async () => ({ + resources: [ + { + name: "Overridden Name", + uri: "test://resource/1", + description: "Overridden description", + mimeType: "text/markdown", + // Add any other metadata fields if they exist + }, + ], + }), + }), + { + name: "Template Name", + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe("Overridden Name"); + expect(result.resources[0].description).toBe("Overridden description"); + expect(result.resources[0].mimeType).toBe("text/markdown"); + }); }); From ea6d97d5abe21fb147d49ede26eafbeee4e89ca1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 19:21:45 +0100 Subject: [PATCH 090/194] 1.12.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 764ce2cb..6b184f31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.2", + "version": "1.12.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4aaa26d900d452b61165cc440776b8e960b88a99 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 13 Jun 2025 13:35:38 +0100 Subject: [PATCH 091/194] remove protocol version mismatch warning --- src/server/streamableHttp.test.ts | 7 +------ src/server/streamableHttp.ts | 4 ---- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index f00721e2..d66083fe 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -791,7 +791,7 @@ describe("StreamableHTTPServerTransport", () => { expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); - it("should accept but warn when protocol version differs from negotiated version", async () => { + it("should accept when protocol version differs from negotiated version", async () => { sessionId = await initializeServer(); // Spy on console.warn to verify warning is logged @@ -812,11 +812,6 @@ describe("StreamableHTTPServerTransport", () => { // Request should still succeed expect(response.status).toBe(200); - // But warning should have been logged - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Request has header with protocol version 2024-11-05, but version previously negotiated is 2025-03-26") - ); - warnSpy.mockRestore(); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c6010fd3..62503120 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -543,10 +543,6 @@ export class StreamableHTTPServerTransport implements Transport { protocolVersion = protocolVersion[protocolVersion.length - 1]; } - - if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { - console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); - } if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", From 33257f14d4d4e8b09b20f58126ab1fb8ef9e05ab Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 13 Jun 2025 13:36:04 +0100 Subject: [PATCH 092/194] switch to optional Transport.setProtocolVersion --- src/client/index.ts | 4 +++- src/client/sse.ts | 10 +++++++--- src/client/streamableHttp.ts | 13 ++++++++++--- src/server/index.ts | 3 --- src/server/sse.ts | 2 -- src/server/streamableHttp.ts | 1 - src/shared/transport.ts | 6 ++---- 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 0e7d6cf4..a8fbdcee 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -166,7 +166,9 @@ export class Client< this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; // HTTP transports must set the protocol version in each header after initialization. - transport.protocolVersion = result.protocolVersion; + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } this._instructions = result.instructions; diff --git a/src/client/sse.ts b/src/client/sse.ts index 3111cd9d..5aa99abb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -62,7 +62,7 @@ export class SSEClientTransport implements Transport { private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; - protocolVersion?: string; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -109,8 +109,8 @@ export class SSEClientTransport implements Transport { headers["Authorization"] = `Bearer ${tokens.access_token}`; } } - if (this.protocolVersion) { - headers["mcp-protocol-version"] = this.protocolVersion; + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; } return headers; @@ -255,4 +255,8 @@ export class SSEClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 2a1895f5..4117bb1b 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -124,7 +124,7 @@ export class StreamableHTTPClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; - protocolVersion?: string; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -174,8 +174,8 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } - if (this.protocolVersion) { - headers["mcp-protocol-version"] = this.protocolVersion; + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; } return new Headers( @@ -520,4 +520,11 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + get protocolVersion(): string | undefined { + return this._protocolVersion; + } } diff --git a/src/server/index.ts b/src/server/index.ts index d05c5489..caf72f9c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -254,9 +254,6 @@ export class Server< const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; - if (this.transport) { - this.transport.protocolVersion = protocolVersion; - } return { protocolVersion, diff --git a/src/server/sse.ts b/src/server/sse.ts index b2f85ce0..e9a4d53a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -17,8 +17,6 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; - - protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 62503120..34b2ab68 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -111,7 +111,6 @@ export class StreamableHTTPServerTransport implements Transport { private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string; - protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 976be79d..b75e072e 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -77,9 +77,7 @@ export interface Transport { sessionId?: string; /** - * The protocol version used for the connection. - * - * Only set after the initialize response was received. + * Sets the protocol version used for the connection (called when the initialize response is received). */ - protocolVersion?: string; + setProtocolVersion?: (version: string) => void; } From db9ba7b419bfe0e0686576c5365e8a969bd97637 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 14 Jun 2025 01:08:02 +0300 Subject: [PATCH 093/194] raw request propagation in tools - implementation, unit tests, types --- package-lock.json | 4 +- package.json | 2 +- src/client/index.test.ts | 13 +- src/server/index.test.ts | 14 +- src/server/mcp.test.ts | 367 ++++++++++++++---------------- src/server/mcp.ts | 4 + src/server/sse.test.ts | 204 ++++++++++++++++- src/server/sse.ts | 8 +- src/server/streamableHttp.test.ts | 65 ++++++ src/server/streamableHttp.ts | 8 +- src/server/types/types.ts | 31 +++ src/shared/protocol.test.ts | 126 ++++++++++ src/shared/protocol.ts | 14 +- src/shared/transport.ts | 7 +- 14 files changed, 641 insertions(+), 226 deletions(-) create mode 100644 src/server/types/types.ts diff --git a/package-lock.json b/package-lock.json index 40bad9fe..1a9a8f45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.0", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 467800fc..764ce2cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", + "version": "1.12.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index bbfa80fa..f80459f1 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -20,7 +20,14 @@ import { import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; +import { RequestInfo } from "../server/types/types.js"; +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + }, +}; /*** * Test: Initialize with Matching Protocol Version */ @@ -42,7 +49,7 @@ test("should initialize with matching protocol version", async () => { }, instructions: "test instructions", }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), @@ -100,7 +107,7 @@ test("should initialize with supported older protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), @@ -150,7 +157,7 @@ test("should reject unsupported protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); } return Promise.resolve(); }), diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7c0fbc51..e015be94 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -19,6 +19,14 @@ import { import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; import { Client } from "../client/index.js"; +import { RequestInfo } from "./types/types.js"; + +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', + }, +}; test("should accept latest protocol version", async () => { let sendPromiseResolve: (value: unknown) => void; @@ -77,7 +85,7 @@ test("should accept latest protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -138,7 +146,7 @@ test("should accept supported older protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -198,7 +206,7 @@ test("should handle unsupported protocol version", async () => { version: "1.0", }, }, - }); + }, { requestInfo: mockRequestInfo }); await expect(sendPromise).resolves.toBeUndefined(); }); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..773777cb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,14 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { RequestInfo } from "./types/types.js"; + +const mockRequestInfo: RequestInfo = { + headers: { + 'content-type': 'application/json', + 'accept': 'application/json', + }, +}; describe("McpServer", () => { /*** @@ -212,7 +220,8 @@ describe("ResourceTemplate", () => { signal: abortController.signal, requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, - sendNotification: () => { throw new Error("Not implemented") } + sendNotification: () => { throw new Error("Not implemented") }, + requestInfo: mockRequestInfo }); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); @@ -913,18 +922,10 @@ describe("tool()", () => { name: "test server", version: "1.0", }); - - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1056,17 +1057,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema mcpServer.registerTool( @@ -1169,17 +1163,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns only content without structuredContent mcpServer.registerTool( @@ -1233,17 +1220,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns invalid data mcpServer.registerTool( @@ -1308,17 +1288,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedSessionId: string | undefined; mcpServer.tool("test-tool", async (extra) => { @@ -1364,17 +1337,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.tool("request-id-test", async (extra) => { @@ -1423,17 +1389,10 @@ describe("tool()", () => { { capabilities: { logging: {} } }, ); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedLogMessage: string | undefined; const loggingMessage = "hello here is log message 1"; @@ -1480,17 +1439,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1546,17 +1498,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("error-test", async () => { throw new Error("Tool execution failed"); @@ -1598,17 +1543,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("test-tool", async () => ({ content: [ @@ -2392,6 +2330,48 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a resource template with a complete callback is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{category}", { + list: undefined, + complete: { + category: () => ["books", "movies", "music"], + }, + }), + async () => ({ + contents: [ + { + uri: "test://resource/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Resource Template Parameter Completion */ @@ -2401,17 +2381,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2469,17 +2442,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2540,17 +2506,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { @@ -3052,17 +3011,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test", @@ -3258,17 +3210,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt("test-prompt", async () => ({ messages: [ @@ -3303,6 +3248,49 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a prompt with a completable argument is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Prompt Argument Completion */ @@ -3312,17 +3300,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3380,17 +3361,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3450,17 +3424,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.prompt("request-id-test", async (extra) => { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..38c869c7 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -236,6 +236,10 @@ export class McpServer { CompleteRequestSchema.shape.method.value, ); + this.server.registerCapabilities({ + completions: {}, + }); + this.server.setRequestHandler( CompleteRequestSchema, async (request): Promise => { diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 2fd2c042..7edef6af 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,20 +1,146 @@ import http from 'http'; import { jest } from '@jest/globals'; import { SSEServerTransport } from './sse.js'; +import { McpServer } from './mcp.js'; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { z } from 'zod'; +import { CallToolResult, JSONRPCMessage } from 'src/types.js'; const createMockResponse = () => { const res = { - writeHead: jest.fn(), - write: jest.fn().mockReturnValue(true), - on: jest.fn(), + writeHead: jest.fn().mockReturnThis(), + write: jest.fn().mockReturnThis(), + on: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), }; - res.writeHead.mockReturnThis(); - res.on.mockReturnThis(); - return res as unknown as http.ServerResponse; + return res as unknown as jest.Mocked; }; +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServerWithSse(args: { + mockRes: http.ServerResponse; +}): Promise<{ + server: Server; + transport: SSEServerTransport; + mcpServer: McpServer; + baseUrl: URL; + sessionId: string + serverPort: number; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + const endpoint = '/messages'; + + const transport = new SSEServerTransport(endpoint, args.mockRes); + const sessionId = transport.sessionId; + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + await transport.handlePostMessage(req, res); + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + const port = (server.address() as AddressInfo).port; + + return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; +} + +async function readAllSSEEvents(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('No readable stream'); + + const events: string[] = []; + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value) { + events.push(decoder.decode(value)); + } + } + } finally { + reader.releaseLock(); + } + + return events; +} + +/** + * Helper to send JSON-RPC request + */ +async function sendSsePostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...extraHeaders + }; + + if (sessionId) { + baseUrl.searchParams.set('sessionId', sessionId); + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + describe('SSEServerTransport', () => { + + async function initializeServer(baseUrl: URL): Promise { + const response = await sendSsePostRequest(baseUrl, { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + capabilities: { + }, + }, + + id: "init-1", + } as JSONRPCMessage); + + expect(response.status).toBe(202); + + const text = await readAllSSEEvents(response); + + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } + describe('start method', () => { it('should correctly append sessionId to a simple relative endpoint', async () => { const mockRes = createMockResponse(); @@ -105,5 +231,71 @@ describe('SSEServerTransport', () => { `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` ); }); + + /*** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + const mockRes = createMockResponse(); + const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); + await initializeServer(baseUrl); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + + expect(response.status).toBe(202); + + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + + const expectedMessage = { + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", + }, + { + type: "text", + text: JSON.stringify({ + headers: { + host: `127.0.0.1:${serverPort}`, + connection: 'keep-alive', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'node', + 'accept-encoding': 'gzip, deflate', + 'content-length': '124' + }, + }) + }, + ], + }, + jsonrpc: "2.0", + id: "call-1", + }; + expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); + }); }); }); diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..bac58c80 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -5,6 +5,7 @@ import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { AuthInfo } from "./auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "./types/types.js"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -20,7 +21,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -87,6 +88,7 @@ export class SSEServerTransport implements Transport { throw new Error(message); } const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; let body: string | unknown; try { @@ -106,7 +108,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -118,7 +120,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { + async handleMessage(message: unknown, extra: MessageExtraInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..83af86cc 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -206,6 +206,7 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag describe("StreamableHTTPServerTransport", () => { let server: Server; + let mcpServer: McpServer; let transport: StreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -214,6 +215,7 @@ describe("StreamableHTTPServerTransport", () => { const result = await createTestServer(); server = result.server; transport = result.transport; + mcpServer = result.mcpServer; baseUrl = result.baseUrl; }); @@ -345,6 +347,69 @@ describe("StreamableHTTPServerTransport", () => { }); }); + /*** + * Test: Tool With Request Info + */ + it("should pass request info to tool callback", async () => { + sessionId = await initializeServer(); + + mcpServer.tool( + "test-request-info", + "A simple test tool with request info", + { name: z.string().describe("Name to greet") }, + async ({ name }, { requestInfo }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "test-request-info", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { type: "text", text: "Hello, Test User!" }, + { type: "text", text: expect.any(String) } + ], + }, + id: "call-1", + }); + + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String), + }, + }); + }); + it("should reject requests without a valid session ID", async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..77941095 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -5,6 +5,7 @@ import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; import { AuthInfo } from "./auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "./types/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -113,7 +114,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string | undefined; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; @@ -318,6 +319,7 @@ export class StreamableHTTPServerTransport implements Transport { } const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; let rawMessage; if (parsedBody !== undefined) { @@ -395,7 +397,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message, { authInfo }); + this.onmessage?.(message, { authInfo, requestInfo }); } } else if (hasRequests) { // The default behavior is to use SSE streaming @@ -430,7 +432,7 @@ export class StreamableHTTPServerTransport implements Transport { // handle each message for (const message of messages) { - this.onmessage?.(message, { authInfo }); + this.onmessage?.(message, { authInfo, requestInfo }); } // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses // This will be handled by the send() method when responses are ready diff --git a/src/server/types/types.ts b/src/server/types/types.ts new file mode 100644 index 00000000..1114e50b --- /dev/null +++ b/src/server/types/types.ts @@ -0,0 +1,31 @@ +import { AuthInfo } from "../auth/types.js"; + +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; +} \ No newline at end of file diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..05bc8f3b 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -27,9 +27,11 @@ class MockTransport implements Transport { describe("protocol tests", () => { let protocol: Protocol; let transport: MockTransport; + let sendSpy: jest.SpyInstance; beforeEach(() => { transport = new MockTransport(); + sendSpy = jest.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -63,6 +65,130 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + describe("_meta preservation with onprogress", () => { + test("should preserve existing _meta when adding progressToken", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should create _meta with progressToken when no _meta exists", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test" + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should not modify _meta when onprogress is not provided", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + + protocol.request(request, mockSchema); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should handle params being undefined with onprogress", async () => { + await protocol.connect(transport); + const request = { + method: "example" + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + }); + describe("progress notification timeout behavior", () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4694929d..ae539c17 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -25,6 +25,7 @@ import { } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; +import { MessageExtraInfo, RequestInfo } from "../server/types/types.js"; /** * Callback for progress notifications. @@ -127,6 +128,11 @@ export type RequestHandlerExtra void; + onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; /** * The session ID generated for this connection. From e7284d29c8271f62208caf4a90d70d2a2cd46bee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 18:47:44 +0100 Subject: [PATCH 094/194] added _meta to more objects --- src/types.ts | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 740b6c43..822ba8d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,8 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications. - */ + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -58,8 +58,8 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses. - */ + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -463,6 +463,11 @@ export const ResourceSchema = z * The MIME type of this resource, if known. */ mimeType: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -494,6 +499,11 @@ export const ResourceTemplateSchema = z * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. */ mimeType: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -677,6 +687,11 @@ export const TextContentSchema = z * The text content of the message. */ text: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -694,6 +709,11 @@ export const ImageContentSchema = z * The MIME type of the image. Different providers may support different image types. */ mimeType: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -711,6 +731,11 @@ export const AudioContentSchema = z * The MIME type of the audio. Different providers may support different audio types. */ mimeType: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -847,12 +872,17 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), required: z.optional(z.array(z.string())), }) - .passthrough() + .passthrough() ), /** * Optional additional tool information. */ annotations: z.optional(ToolAnnotationsSchema), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -1182,6 +1212,11 @@ export const RootSchema = z * An optional name for the root. */ name: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); From 4d03b243e37a6b1965c8cbe601cd37c94b9b0080 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 20:42:25 +0100 Subject: [PATCH 095/194] add title --- README.md | 138 +++++++++--- src/examples/client/simpleStreamableHttp.ts | 7 +- src/examples/server/simpleStreamableHttp.ts | 29 ++- src/server/mcp.ts | 158 +++++++++++++- src/server/title.test.ts | 194 +++++++++++++++++ src/shared/metadataUtils.ts | 21 ++ src/types.ts | 227 +++++++++----------- 7 files changed, 601 insertions(+), 173 deletions(-) create mode 100644 src/server/title.test.ts create mode 100644 src/shared/metadataUtils.ts diff --git a/README.md b/README.md index c9e27c27..d13378b5 100644 --- a/README.md +++ b/README.md @@ -54,26 +54,35 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ - name: "Demo", - version: "1.0.0" + name: "demo-server", + version: "1.0.0", + title: "Demo Server" // Optional display name }); // Add an addition tool -server.tool("add", - { a: z.number(), b: z.number() }, +server.registerTool("add", + { + title: "Addition Tool", + description: "Add two numbers", + inputSchema: { a: z.number(), b: z.number() } + }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }) ); // Add a dynamic greeting resource -server.resource( +server.registerResource( "greeting", new ResourceTemplate("greeting://{name}", { list: undefined }), - async (uri, { name }) => ({ + { + title: "Greeting Resource", // Display name for UI + description: "Dynamic greeting generator" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Hello, ${name}!` + text: `Hello, ${params.name}!` }] }) ); @@ -100,8 +109,9 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "My App", - version: "1.0.0" + name: "my-app", // Unique identifier for your server + version: "1.0.0", // Server version + title: "My Application" // Optional display name for UI }); ``` @@ -111,9 +121,14 @@ Resources are how you expose data to LLMs. They're similar to GET endpoints in a ```typescript // Static resource -server.resource( +server.registerResource( "config", "config://app", + { + title: "Application Config", + description: "Application configuration data", + mimeType: "text/plain" + }, async (uri) => ({ contents: [{ uri: uri.href, @@ -123,13 +138,17 @@ server.resource( ); // Dynamic resource with parameters -server.resource( +server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), - async (uri, { userId }) => ({ + { + title: "User Profile", + description: "User profile information" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${userId}` + text: `Profile data for user ${params.userId}` }] }) ); @@ -141,11 +160,15 @@ Tools let LLMs take actions through your server. Unlike resources, tools are exp ```typescript // Simple tool with parameters -server.tool( +server.registerTool( "calculate-bmi", { - weightKg: z.number(), - heightM: z.number() + title: "BMI Calculator", + description: "Calculate Body Mass Index", + inputSchema: { + weightKg: z.number(), + heightM: z.number() + } }, async ({ weightKg, heightM }) => ({ content: [{ @@ -156,9 +179,13 @@ server.tool( ); // Async tool with external API call -server.tool( +server.registerTool( "fetch-weather", - { city: z.string() }, + { + title: "Weather Fetcher", + description: "Get weather data for a city", + inputSchema: { city: z.string() } + }, async ({ city }) => { const response = await fetch(`https://api.weather.com/${city}`); const data = await response.text(); @@ -174,9 +201,13 @@ server.tool( Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript -server.prompt( +server.registerPrompt( "review-code", - { code: z.string() }, + { + title: "Code Review", + description: "Review code for best practices and potential issues", + arguments: { code: z.string() } + }, ({ code }) => ({ messages: [{ role: "user", @@ -189,6 +220,22 @@ server.prompt( ); ``` +### Display Names and Metadata + +All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. + +**Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. + + +When building clients, use the provided utility to get the appropriate display name: + +```typescript +import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; + +// Falls back to 'name' if 'title' is not provided +const displayName = getDisplayName(tool); // Returns title if available, otherwise name +``` + ## 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: @@ -401,32 +448,45 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc import { z } from "zod"; const server = new McpServer({ - name: "Echo", - version: "1.0.0" + name: "echo-server", + version: "1.0.0", + title: "Echo Server" }); -server.resource( +server.registerResource( "echo", new ResourceTemplate("echo://{message}", { list: undefined }), - async (uri, { message }) => ({ + { + title: "Echo Resource", + description: "Echoes back messages as resources" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${message}` + text: `Resource echo: ${params.message}` }] }) ); -server.tool( +server.registerTool( "echo", - { message: z.string() }, + { + title: "Echo Tool", + description: "Echoes back the provided message", + inputSchema: { message: z.string() } + }, async ({ message }) => ({ content: [{ type: "text", text: `Tool echo: ${message}` }] }) ); -server.prompt( +server.registerPrompt( "echo", - { message: z.string() }, + { + title: "Echo Prompt", + description: "Creates a prompt to process a message", + arguments: { message: z.string() } + }, ({ message }) => ({ messages: [{ role: "user", @@ -450,8 +510,9 @@ import { promisify } from "util"; import { z } from "zod"; const server = new McpServer({ - name: "SQLite Explorer", - version: "1.0.0" + name: "sqlite-explorer", + version: "1.0.0", + title: "SQLite Explorer" }); // Helper to create DB connection @@ -463,9 +524,14 @@ const getDb = () => { }; }; -server.resource( +server.registerResource( "schema", "schema://main", + { + title: "Database Schema", + description: "SQLite database schema", + mimeType: "text/plain" + }, async (uri) => { const db = getDb(); try { @@ -484,9 +550,13 @@ server.resource( } ); -server.tool( +server.registerTool( "query", - { sql: z.string() }, + { + title: "SQL Query", + description: "Execute SQL queries on the database", + inputSchema: { sql: z.string() } + }, async ({ sql }) => { const db = getDb(); try { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 19d32bbc..63d7d60a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, } from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; // Create readline interface for user input const readline = createInterface({ @@ -317,7 +318,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); + console.log(` - ${getDisplayName(tool)}: ${tool.description}`); } } } catch (error) { @@ -429,7 +430,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${prompt.name}: ${prompt.description}`); + console.log(` - ${getDisplayName(prompt)}: ${prompt.description}`); } } } catch (error) { @@ -480,7 +481,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${resource.name}: ${resource.uri}`); + console.log(` - ${getDisplayName(resource)}: ${resource.uri}`); } } } catch (error) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c331192..b66be93d 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -18,14 +18,18 @@ const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', version: '1.0.0', + title: 'Simple Streamable HTTP Server', // Display name for UI }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting - server.tool( + server.registerTool( 'greet', - 'A simple greeting tool', { - name: z.string().describe('Name to greet'), + title: 'Greeting Tool', // Display name for UI + description: 'A simple greeting tool', + inputSchema: { + name: z.string().describe('Name to greet'), + }, }, async ({ name }): Promise => { return { @@ -84,12 +88,15 @@ const getServer = () => { } ); - // Register a simple prompt - server.prompt( + // Register a simple prompt with title + server.registerPrompt( 'greeting-template', - 'A simple greeting prompt template', { - name: z.string().describe('Name to include in greeting'), + title: 'Greeting Template', // Display name for UI + description: 'A simple greeting prompt template', + arguments: { + name: z.string().describe('Name to include in greeting'), + }, }, async ({ name }): Promise => { return { @@ -148,10 +155,14 @@ const getServer = () => { ); // Create a simple resource at a fixed URI - server.resource( + server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, + { + title: 'Default Greeting', // Display name for UI + description: 'A simple greeting resource', + mimeType: 'text/plain' + }, async (): Promise => { return { contents: [ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7fba043f..7c77add1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -122,6 +122,10 @@ export class McpServer { annotations: tool.annotations, }; + if (tool.title !== undefined) { + toolDefinition.title = tool.title; + } + if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -467,13 +471,19 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - return { + const promptDefinition: Prompt = { name, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; + + if (prompt.title !== undefined) { + promptDefinition.title = prompt.title; + } + + return promptDefinition; }, ), }), @@ -634,6 +644,83 @@ 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 | ResourceTemplate, + config: ResourceMetadata & { + title?: string; + description?: string; + mimeType?: string; + }, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === "string") { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource: RegisteredResource = { + name, + metadata: config, + readCallback: readCallback as ReadResourceCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { + delete this._registeredResources[uriOrTemplate] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uriOrTemplate] = registeredResource; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; + } else { + if (this._registeredResourceTemplates[name]) { + throw new Error(`Resource template ${name} is already registered`); + } + + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: uriOrTemplate, + metadata: config, + readCallback: readCallback as ReadResourceTemplateCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + private _createRegisteredTool( name: string, description: string | undefined, @@ -659,6 +746,7 @@ export class McpServer { delete this._registeredTools[name] if (updates.name) this._registeredTools[updates.name] = registeredTool } + if (typeof updates.title !== "undefined") registeredTool.title = updates.title if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback @@ -789,6 +877,7 @@ export class McpServer { registerTool( name: string, config: { + title?: string; description?: string; inputSchema?: InputArgs; outputSchema?: OutputArgs; @@ -800,16 +889,23 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { description, inputSchema, outputSchema, annotations } = config; + const { title, description, inputSchema, outputSchema, annotations } = config; - return this._createRegisteredTool( + const registeredTool = this._createRegisteredTool( name, description, inputSchema, outputSchema, annotations, cb as ToolCallback - ) + ); + + // Set title if provided + if (title !== undefined) { + registeredTool.title = title; + } + + return registeredTool; } /** @@ -870,6 +966,7 @@ export class McpServer { delete this._registeredPrompts[name] if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback @@ -885,6 +982,54 @@ export class McpServer { return registeredPrompt } + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + arguments?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, arguments: argsSchema } = config; + + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback: cb as PromptCallback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + + this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -1000,6 +1145,7 @@ export type ToolCallback = : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { + title?: string; description?: string; inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; @@ -1011,6 +1157,7 @@ export type RegisteredTool = { update( updates: { name?: string | null, + title?: string, description?: string, paramsSchema?: InputArgs, outputSchema?: OutputArgs, @@ -1110,13 +1257,14 @@ export type PromptCallback< : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { + title?: string; description?: string; argsSchema?: ZodObject; callback: PromptCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/server/title.test.ts b/src/server/title.test.ts new file mode 100644 index 00000000..7874fb62 --- /dev/null +++ b/src/server/title.test.ts @@ -0,0 +1,194 @@ +import { Server } from "./index.js"; +import { Client } from "../client/index.js"; +import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; +import { McpServer } from "./mcp.js"; + +describe("Title field backwards compatibility", () => { + it("should work with tools that have title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool with title + server.registerTool( + "test-tool", + { + title: "Test Tool Display Name", + description: "A test tool", + inputSchema: { + value: z.string() + } + }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBe("Test Tool Display Name"); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with tools without title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool without title + server.tool( + "test-tool", + "A test tool", + { value: z.string() }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with prompts that have title using update", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title by updating after creation + const prompt = server.prompt( + "test-prompt", + "A test prompt", + async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) + ); + prompt.update({ title: "Test Prompt Display Name" }); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + }); + + it("should work with prompts using registerPrompt", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title using registerPrompt + server.registerPrompt( + "test-prompt", + { + title: "Test Prompt Display Name", + description: "A test prompt", + arguments: { input: z.string() } + }, + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it("should work with resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register resource with title using registerResource + server.registerResource( + "test-resource", + "https://example.com/test", + { + title: "Test Resource Display Name", + description: "A test resource", + mimeType: "text/plain" + }, + async () => ({ + contents: [{ + uri: "https://example.com/test", + text: "test content" + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe("test-resource"); + expect(resources.resources[0].title).toBe("Test Resource Display Name"); + expect(resources.resources[0].description).toBe("A test resource"); + expect(resources.resources[0].mimeType).toBe("text/plain"); + }); + + it("should support serverInfo with title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: "test-server", + version: "1.0.0", + title: "Test Server Display Name" + }, + { capabilities: {} } + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe("test-server"); + expect(serverInfo?.version).toBe("1.0.0"); + expect(serverInfo?.title).toBe("Test Server Display Name"); + }); +}); \ No newline at end of file diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts new file mode 100644 index 00000000..d581a6b2 --- /dev/null +++ b/src/shared/metadataUtils.ts @@ -0,0 +1,21 @@ +import { BaseMetadata } from "../types.js"; + +/** + * Utilities for working with BaseMetadata objects. + */ + +/** + * Gets the display name for an object with BaseMetadata. + * Returns the title if available, otherwise falls back to name. + * This implements the spec requirement: "if no title is provided, name should be used for display purposes" + */ +export function getDisplayName(metadata: BaseMetadata): string { + return metadata.title ?? metadata.name; +} + +/** + * Checks if an object has a custom title different from its name. + */ +export function hasCustomTitle(metadata: BaseMetadata): boolean { + return metadata.title !== undefined && metadata.title !== metadata.name; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 822ba8d7..9ece70d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,16 +195,26 @@ export const CancelledNotificationSchema = NotificationSchema.extend({ }), }); +/* Base Metadata */ +/** + * Base metadata interface for common properties across resources, tools, prompts, and implementations. + */ +export const BaseMetadataSchema = z + .object({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: z.string(), + /** Intended for UI and end-user contexts — optimized to be human-readable */ + title: z.optional(z.string()), + }) + .passthrough(); + /* Initialization */ /** * Describes the name and version of an MCP implementation. */ -export const ImplementationSchema = z - .object({ - name: z.string(), - version: z.string(), - }) - .passthrough(); +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. @@ -438,74 +448,56 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ /** * A known resource that the server is capable of reading. */ -export const ResourceSchema = z - .object({ - /** - * The URI of this resource. - */ - uri: z.string(), +export const ResourceSchema = BaseMetadataSchema.extend({ + /** + * The URI of this resource. + */ + uri: z.string(), - /** - * A human-readable name for this resource. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this resource represents. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type of this resource, if known. - */ - mimeType: z.optional(z.string()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * A template description for resources available on the server. */ -export const ResourceTemplateSchema = z - .object({ - /** - * A URI template (according to RFC 6570) that can be used to construct resource URIs. - */ - uriTemplate: z.string(), +export const ResourceTemplateSchema = BaseMetadataSchema.extend({ + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: z.string(), - /** - * A human-readable name for the type of resource this template refers to. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this template is for. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - */ - mimeType: z.optional(z.string()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of resources the server has. @@ -629,22 +621,16 @@ export const PromptArgumentSchema = z /** * A prompt or prompt template that the server offers. */ -export const PromptSchema = z - .object({ - /** - * The name of the prompt or prompt template. - */ - name: z.string(), - /** - * An optional description of what this prompt provides - */ - description: z.optional(z.string()), - /** - * A list of arguments to use for templating the prompt. - */ - arguments: z.optional(z.array(PromptArgumentSchema)), - }) - .passthrough(); +export const PromptSchema = BaseMetadataSchema.extend({ + /** + * An optional description of what this prompt provides + */ + description: z.optional(z.string()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: z.optional(z.array(PromptArgumentSchema)), +}); /** * Sent from the client to request a list of prompts and prompt templates the server has. @@ -842,49 +828,43 @@ export const ToolAnnotationsSchema = z /** * Definition for a tool the client can call. */ -export const ToolSchema = z - .object({ - /** - * The name of the tool. - */ - name: z.string(), - /** - * A human-readable description of the tool. - */ - description: z.optional(z.string()), - /** - * A JSON Schema object defining the expected parameters for the tool. - */ - inputSchema: z - .object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) - .passthrough(), - /** - * An optional JSON Schema object defining the structure of the tool's output returned in - * the structuredContent field of a CallToolResult. - */ - outputSchema: z.optional( - z.object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) - .passthrough() - ), - /** - * Optional additional tool information. - */ - annotations: z.optional(ToolAnnotationsSchema), +export const ToolSchema = BaseMetadataSchema.extend({ + /** + * A human-readable description of the tool. + */ + description: z.optional(z.string()), + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: z + .object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + */ + outputSchema: z.optional( + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), + /** + * Optional additional tool information. + */ + annotations: z.optional(ToolAnnotationsSchema), - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of tools the server has. @@ -1347,6 +1327,9 @@ export type EmptyResult = Infer; /* Cancellation */ export type CancelledNotification = Infer; +/* Base Metadata */ +export type BaseMetadata = Infer; + /* Initialization */ export type Implementation = Infer; export type ClientCapabilities = Infer; From 7e59081b43ca156771470a195794cdff716f3859 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 22:19:53 +0100 Subject: [PATCH 096/194] add resource link --- README.md | 32 +++ src/examples/client/simpleStreamableHttp.ts | 74 ++++++- src/examples/server/simpleStreamableHttp.ts | 95 ++++++++- src/types.test.ts | 211 +++++++++++++++++++- src/types.ts | 37 ++-- 5 files changed, 433 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d13378b5..e7fa574c 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,40 @@ server.registerTool( }; } ); + +// Tool that returns ResourceLinks +server.registerTool( + "list-files", + { + title: "List Files", + description: "List project files", + inputSchema: { pattern: z.string() } + }, + async ({ pattern }) => ({ + content: [ + { type: "text", text: `Found files matching "${pattern}":` }, + // ResourceLinks let tools return references without file content + { + type: "resource_link" as const, + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown" + }, + { + type: "resource_link" as const, + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript" + } + ] + }) +); ``` +#### ResourceLinks + +Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. + ### Prompts Prompts are reusable templates that help LLMs interact with your server effectively: diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..8b539d97 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,6 +14,9 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ReadResourceRequest, + ReadResourceResultSchema, + ResourceLink, } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; @@ -60,6 +63,7 @@ function printHelp(): void { console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); + console.log(' read-resource - Read a specific resource by URI'); console.log(' help - Show this help'); console.log(' quit - Exit the program'); } @@ -155,6 +159,14 @@ function commandLoop(): void { await listResources(); break; + case 'read-resource': + if (args.length < 2) { + console.log('Usage: read-resource '); + } else { + await readResource(args[1]); + } + break; + case 'help': printHelp(); break; @@ -345,13 +357,37 @@ async function callTool(name: string, args: Record): Promise { if (item.type === 'text') { console.log(` ${item.text}`); + } else if (item.type === 'resource_link') { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); } else { - console.log(` ${item.type} content:`, item); + console.log(` [Unknown content type]:`, item); } }); + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } @@ -489,6 +525,42 @@ async function listResources(): Promise { } } +async function readResource(uri: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: ReadResourceRequest = { + method: 'resources/read', + params: { uri } + }; + + console.log(`Reading resource: ${uri}`); + const result = await client.request(request, ReadResourceResultSchema); + + console.log('Resource contents:'); + for (const content of result.contents) { + console.log(` URI: ${content.uri}`); + if (content.mimeType) { + console.log(` Type: ${content.mimeType}`); + } + + if ('text' in content && typeof content.text === 'string') { + console.log(' Content:'); + console.log(' ---'); + console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n')); + console.log(' ---'); + } else if ('blob' in content && typeof content.blob === 'string') { + console.log(` [Binary data: ${content.blob.length} bytes]`); + } + } + } catch (error) { + console.log(`Error reading resource ${uri}: ${error}`); + } +} + async function cleanup(): Promise { if (client && transport) { try { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d..e5d35a82 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult, ResourceLink } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; @@ -174,6 +174,99 @@ const getServer = () => { }; } ); + + // Create additional resources for ResourceLink demonstration + server.registerResource( + 'example-file-1', + 'file:///example/file1.txt', + { + title: 'Example File 1', + description: 'First example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file1.txt', + text: 'This is the content of file 1', + }, + ], + }; + } + ); + + server.registerResource( + 'example-file-2', + 'file:///example/file2.txt', + { + title: 'Example File 2', + description: 'Second example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file2.txt', + text: 'This is the content of file 2', + }, + ], + }; + } + ); + + // Register a tool that returns ResourceLinks + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: { + includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'), + }, + }, + async ({ includeDescriptions = true }): Promise => { + const resourceLinks: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'https://example.com/greetings/default', + name: 'Default Greeting', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'A simple greeting resource' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file1.txt', + name: 'Example File 1', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file2.txt', + name: 'Example File 2', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) + } + ]; + + return { + content: [ + { + type: 'text', + text: 'Here are the available files as resource links:', + }, + ...resourceLinks, + { + type: 'text', + text: '\nYou can read any of these resources using their URI.', + } + ], + }; + } + ); + return server; }; diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..ca49867a 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,4 +1,11 @@ -import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; +import { + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + ResourceLinkSchema, + ContentBlockSchema, + PromptMessageSchema, + CallToolResultSchema +} from "./types.js"; describe("Types", () => { @@ -14,4 +21,206 @@ describe("Types", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); }); + describe("ResourceLink", () => { + test("should validate a minimal ResourceLink", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + expect(result.data.uri).toBe("file:///path/to/file.txt"); + expect(result.data.name).toBe("file.txt"); + } + }); + + test("should validate a ResourceLink with all optional fields", () => { + const resourceLink = { + type: "resource_link", + uri: "https://example.com/resource", + name: "Example Resource", + title: "A comprehensive example resource", + description: "This resource demonstrates all fields", + mimeType: "text/plain", + _meta: { custom: "metadata" } + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe("A comprehensive example resource"); + expect(result.data.description).toBe("This resource demonstrates all fields"); + expect(result.data.mimeType).toBe("text/plain"); + expect(result.data._meta).toEqual({ custom: "metadata" }); + } + }); + + test("should fail validation for invalid type", () => { + const invalidResourceLink = { + type: "invalid_type", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + + test("should fail validation for missing required fields", () => { + const invalidResourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt" + // missing name + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + }); + + describe("ContentBlock", () => { + test("should validate text content", () => { + const textContent = { + type: "text", + text: "Hello, world!" + }; + + const result = ContentBlockSchema.safeParse(textContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("text"); + } + }); + + test("should validate image content", () => { + const imageContent = { + type: "image", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "image/png" + }; + + const result = ContentBlockSchema.safeParse(imageContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("image"); + } + }); + + test("should validate audio content", () => { + const audioContent = { + type: "audio", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "audio/mp3" + }; + + const result = ContentBlockSchema.safeParse(audioContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("audio"); + } + }); + + test("should validate resource link content", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt", + mimeType: "text/plain" + }; + + const result = ContentBlockSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + } + }); + + test("should validate embedded resource content", () => { + const embeddedResource = { + type: "resource", + resource: { + uri: "file:///path/to/file.txt", + mimeType: "text/plain", + text: "File contents" + } + }; + + const result = ContentBlockSchema.safeParse(embeddedResource); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource"); + } + }); + }); + + describe("PromptMessage with ContentBlock", () => { + test("should validate prompt message with resource link", () => { + const promptMessage = { + role: "assistant", + content: { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + } + }; + + const result = PromptMessageSchema.safeParse(promptMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content.type).toBe("resource_link"); + } + }); + }); + + describe("CallToolResult with ContentBlock", () => { + test("should validate tool result with resource links", () => { + const toolResult = { + content: [ + { + type: "text", + text: "Found the following files:" + }, + { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + }, + { + type: "resource_link", + uri: "file:///project/src/lib.rs", + name: "lib.rs", + description: "Library exports", + mimeType: "text/x-rust" + } + ] + }; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toHaveLength(3); + expect(result.data.content[0].type).toBe("text"); + expect(result.data.content[1].type).toBe("resource_link"); + expect(result.data.content[2].type).toBe("resource_link"); + } + }); + + test("should validate empty content array with default", () => { + const toolResult = {}; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toEqual([]); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 9ece70d4..0d051ede 100644 --- a/src/types.ts +++ b/src/types.ts @@ -735,18 +735,33 @@ export const EmbeddedResourceSchema = z }) .passthrough(); +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + */ +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal("resource_link"), +}); + +/** + * A content block that can be used in prompts and tool results. + */ +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema, +]); + /** * Describes a message returned as part of a prompt. */ export const PromptMessageSchema = z .object({ role: z.enum(["user", "assistant"]), - content: z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ]), + content: ContentBlockSchema, }) .passthrough(); @@ -890,13 +905,7 @@ export const CallToolResultSchema = ResultSchema.extend({ * If the Tool does not define an outputSchema, this field MUST be present in the result. * For backwards compatibility, this field is always present, but it may be empty. */ - content: z.array( - z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ])).default([]), + content: z.array(ContentBlockSchema).default([]), /** * An object containing structured tool output. @@ -1376,6 +1385,8 @@ export type TextContent = Infer; export type ImageContent = Infer; export type AudioContent = Infer; export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; export type PromptMessage = Infer; export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; From 6dd0b1ee4d9ff5e2043cfab7f6391ef4e8ff54b0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sun, 15 Jun 2025 21:47:53 -0400 Subject: [PATCH 097/194] Update readme file to include a tip to allow `mcp-session-id` in CORS when --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index c9e27c27..241056e5 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,21 @@ app.delete('/mcp', handleSessionRequest); app.listen(3000); ``` +> [!TIP] +> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. +> +> For example, in Node.js you can configure it like this: +> +> ```ts +> app.use( +> cors({ +> origin: '*', +> exposedHeaders: ['mcp-session-id'], +> allowedHeaders: ['Content-Type', 'mcp-session-id'], +> }) +> ); +> ``` + #### Without Session Management (Stateless) For simpler use cases where session management isn't needed: From d1c94a2c696c131f65419f2e3d68efe4a18a1d27 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:43:31 +0100 Subject: [PATCH 098/194] refactor --- README.md | 26 +- src/examples/client/simpleStreamableHttp.ts | 7 +- src/server/mcp.test.ts | 138 +++++++++ src/server/mcp.ts | 303 ++++++++++---------- src/shared/metadataUtils.ts | 19 +- src/types.ts | 9 +- 6 files changed, 348 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index d13378b5..f9f080b9 100644 --- a/README.md +++ b/README.md @@ -226,14 +226,36 @@ All resources, tools, and prompts support an optional `title` field for better U **Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. +#### Title Precedence for Tools + +For tools specifically, there are two ways to specify a title: +- `title` field in the tool configuration +- `annotations.title` field (when using the older `tool()` method with annotations) + +The precedence order is: `title` → `annotations.title` → `name` + +```typescript +// Using registerTool (recommended) +server.registerTool("my_tool", { + title: "My Tool", // This title takes precedence + annotations: { + title: "Annotation Title" // This is ignored if title is set + } +}, handler); + +// Using tool with annotations (older API) +server.tool("my_tool", "description", { + title: "Annotation Title" // This is used as title +}, handler); +``` When building clients, use the provided utility to get the appropriate display name: ```typescript import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; -// Falls back to 'name' if 'title' is not provided -const displayName = getDisplayName(tool); // Returns title if available, otherwise name +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..efa176df 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,7 +318,12 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${getDisplayName(tool)}: ${tool.description}`); + const displayName = getDisplayName(tool); + if (displayName !== tool.name) { + console.log(` - ${tool.name} (${displayName}): ${tool.description}`); + } else { + console.log(` - ${tool.name}: ${tool.description}`); + } } } } catch (error) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 6ef33540..36a8f7b8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,7 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { getDisplayName } from "../shared/metadataUtils.js"; describe("McpServer", () => { /*** @@ -3598,3 +3599,140 @@ describe("prompt()", () => { expect(result.resources[0].mimeType).toBe("text/markdown"); }); }); + +describe("Tool title precedence", () => { + test("should follow correct title precedence: title → annotations.title → name", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Tool 1: Only name + mcpServer.tool( + "tool_name_only", + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 2: Name and annotations.title + mcpServer.tool( + "tool_with_annotations_title", + "Tool with annotations title", + { + title: "Annotations Title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + "tool_with_title", + { + title: "Regular Title", + description: "Tool with regular title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + "tool_with_all_titles", + { + title: "Regular Title Wins", + description: "Tool with all titles", + annotations: { + title: "Annotations Title Should Not Show" + } + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === "tool_name_only"); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe("tool_name_only"); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === "tool_with_annotations_title"); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe("Annotations Title"); + expect(getDisplayName(tool2!)).toBe("Annotations Title"); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === "tool_with_title"); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe("Regular Title"); + expect(getDisplayName(tool3!)).toBe("Regular Title"); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === "tool_with_all_titles"); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe("Regular Title Wins"); + expect(tool4!.annotations?.title).toBe("Annotations Title Should Not Show"); + expect(getDisplayName(tool4!)).toBe("Regular Title Wins"); + }); + + test("getDisplayName unit tests for title precedence", () => { + + // Test 1: Only name + expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); + + // Test 2: Name and title - title wins + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" + })).toBe("Tool Title"); + + // Test 3: Name and annotations.title - annotations.title wins + expect(getDisplayName({ + name: "tool_name", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 4: All three - title wins (correct precedence) + expect(getDisplayName({ + name: "tool_name", + title: "Regular Title", + annotations: { title: "Annotations Title" } + })).toBe("Regular Title"); + + // Test 5: Empty title should not be used + expect(getDisplayName({ + name: "tool_name", + title: "", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 6: Undefined vs null handling + expect(getDisplayName({ + name: "tool_name", + title: undefined, + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7c77add1..3c1c00d9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -22,6 +22,7 @@ import { CompleteResult, PromptReference, ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -128,7 +129,7 @@ export class McpServer { if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( - tool.outputSchema, + tool.outputSchema, { strictUnions: true } ) as Tool["outputSchema"]; } @@ -586,27 +587,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -616,27 +603,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -651,11 +624,7 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata & { - title?: string; - description?: string; - mimeType?: string; - }, + config: ResourceMetadata, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { if (typeof uriOrTemplate === "string") { @@ -663,27 +632,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, - metadata: config, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -693,27 +648,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, - metadata: config, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -721,8 +662,108 @@ export class McpServer { } } + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const registeredResource: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uri) { + delete this._registeredResources[uri] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.title !== "undefined") registeredResource.title = updates.title + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uri] = registeredResource; + return registeredResource; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.title !== "undefined") registeredResourceTemplate.title = updates.title + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + return registeredResourceTemplate; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: PromptArgsRawShape | undefined, + callback: PromptCallback + ): RegisteredPrompt { + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + return registeredPrompt; + } + private _createRegisteredTool( name: string, + title: string | undefined, description: string | undefined, inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, @@ -730,6 +771,7 @@ export class McpServer { callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { + title, description, inputSchema: inputSchema === undefined ? undefined : z.object(inputSchema), @@ -868,7 +910,7 @@ export class McpServer { } const callback = rest[0] as ToolCallback; - return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback) + return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback) } /** @@ -891,21 +933,15 @@ export class McpServer { const { title, description, inputSchema, outputSchema, annotations } = config; - const registeredTool = this._createRegisteredTool( + return this._createRegisteredTool( name, + title, description, inputSchema, outputSchema, annotations, cb as ToolCallback ); - - // Set title if provided - if (title !== undefined) { - registeredTool.title = title; - } - - return registeredTool; } /** @@ -953,28 +989,13 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, + undefined, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1000,29 +1021,13 @@ export class McpServer { const { title, description, arguments: argsSchema } = config; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, title, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb as PromptCallback, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb as PromptCallback + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1155,16 +1160,16 @@ export type RegisteredTool = { enable(): void; disable(): void; update( - updates: { - name?: string | null, + updates: { + name?: string | null, title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1212,12 +1217,13 @@ export type ReadResourceCallback = ( export type RegisteredResource = { name: string; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void + update(updates: { name?: string, title?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void remove(): void }; @@ -1232,12 +1238,13 @@ export type ReadResourceTemplateCallback = ( export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index d581a6b2..410827a5 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -6,11 +6,26 @@ import { BaseMetadata } from "../types.js"; /** * Gets the display name for an object with BaseMetadata. - * Returns the title if available, otherwise falls back to name. + * For tools, the precedence is: title → annotations.title → name + * For other objects: title → name * This implements the spec requirement: "if no title is provided, name should be used for display purposes" */ export function getDisplayName(metadata: BaseMetadata): string { - return metadata.title ?? metadata.name; + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata) { + const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; + if (metadataWithAnnotations.annotations?.title) { + return metadataWithAnnotations.annotations.title; + } + } + + // Finally fall back to name + return metadata.name; } /** diff --git a/src/types.ts b/src/types.ts index 9ece70d4..4d1d14d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,7 +203,14 @@ export const BaseMetadataSchema = z .object({ /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ name: z.string(), - /** Intended for UI and end-user contexts — optimized to be human-readable */ + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ title: z.optional(z.string()), }) .passthrough(); From bce2dbfb83c6936be6ab05598d30b3d4d5e09d82 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:55:02 +0100 Subject: [PATCH 099/194] small fixes like argsSchema --- README.md | 13 ++-- src/examples/server/simpleStreamableHttp.ts | 9 ++- src/server/mcp.ts | 4 +- src/server/title.test.ts | 80 ++++++++++----------- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f9f080b9..5ae9e043 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,7 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ name: "demo-server", - version: "1.0.0", - title: "Demo Server" // Optional display name + version: "1.0.0" }); // Add an addition tool @@ -109,9 +108,8 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "my-app", // Unique identifier for your server - version: "1.0.0", // Server version - title: "My Application" // Optional display name for UI + name: "my-app", + version: "1.0.0" }); ``` @@ -471,8 +469,7 @@ import { z } from "zod"; const server = new McpServer({ name: "echo-server", - version: "1.0.0", - title: "Echo Server" + version: "1.0.0" }); server.registerResource( @@ -507,7 +504,7 @@ server.registerPrompt( { title: "Echo Prompt", description: "Creates a prompt to process a message", - arguments: { message: z.string() } + argsSchema: { message: z.string() } }, ({ message }) => ({ messages: [{ diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d..fca0ff5a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -17,8 +17,7 @@ const useOAuth = process.argv.includes('--oauth'); const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', - version: '1.0.0', - title: 'Simple Streamable HTTP Server', // Display name for UI + version: '1.0.0' }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting @@ -94,7 +93,7 @@ const getServer = () => { { title: 'Greeting Template', // Display name for UI description: 'A simple greeting prompt template', - arguments: { + argsSchema: { name: z.string().describe('Name to include in greeting'), }, }, @@ -158,10 +157,10 @@ const getServer = () => { server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { + { title: 'Default Greeting', // Display name for UI description: 'A simple greeting resource', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3c1c00d9..7394ecbf 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1011,7 +1011,7 @@ export class McpServer { config: { title?: string; description?: string; - arguments?: Args; + argsSchema?: Args; }, cb: PromptCallback ): RegisteredPrompt { @@ -1019,7 +1019,7 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, arguments: argsSchema } = config; + const { title, description, argsSchema } = config; const registeredPrompt = this._createRegisteredPrompt( name, diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 7874fb62..02bafe12 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -7,12 +7,12 @@ import { McpServer } from "./mcp.js"; describe("Title field backwards compatibility", () => { it("should work with tools that have title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register tool with title server.registerTool( "test-tool", @@ -25,12 +25,12 @@ describe("Title field backwards compatibility", () => { }, async () => ({ content: [{ type: "text", text: "result" }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const tools = await client.listTools(); expect(tools.tools).toHaveLength(1); expect(tools.tools[0].name).toBe("test-tool"); @@ -40,12 +40,12 @@ describe("Title field backwards compatibility", () => { it("should work with tools without title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register tool without title server.tool( "test-tool", @@ -53,12 +53,12 @@ describe("Title field backwards compatibility", () => { { value: z.string() }, async () => ({ content: [{ type: "text", text: "result" }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const tools = await client.listTools(); expect(tools.tools).toHaveLength(1); expect(tools.tools[0].name).toBe("test-tool"); @@ -68,12 +68,12 @@ describe("Title field backwards compatibility", () => { it("should work with prompts that have title using update", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register prompt with title by updating after creation const prompt = server.prompt( "test-prompt", @@ -81,12 +81,12 @@ describe("Title field backwards compatibility", () => { async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) ); prompt.update({ title: "Test Prompt Display Name" }); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const prompts = await client.listPrompts(); expect(prompts.prompts).toHaveLength(1); expect(prompts.prompts[0].name).toBe("test-prompt"); @@ -96,33 +96,33 @@ describe("Title field backwards compatibility", () => { it("should work with prompts using registerPrompt", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register prompt with title using registerPrompt server.registerPrompt( "test-prompt", { title: "Test Prompt Display Name", description: "A test prompt", - arguments: { input: z.string() } + argsSchema: { input: z.string() } }, - async ({ input }) => ({ - messages: [{ - role: "user", - content: { type: "text", text: `test: ${input}` } - }] + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const prompts = await client.listPrompts(); expect(prompts.prompts).toHaveLength(1); expect(prompts.prompts[0].name).toBe("test-prompt"); @@ -133,12 +133,12 @@ describe("Title field backwards compatibility", () => { it("should work with resources using registerResource", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register resource with title using registerResource server.registerResource( "test-resource", @@ -148,19 +148,19 @@ describe("Title field backwards compatibility", () => { description: "A test resource", mimeType: "text/plain" }, - async () => ({ - contents: [{ + async () => ({ + contents: [{ uri: "https://example.com/test", - text: "test content" - }] + text: "test content" + }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const resources = await client.listResources(); expect(resources.resources).toHaveLength(1); expect(resources.resources[0].name).toBe("test-resource"); @@ -171,21 +171,21 @@ describe("Title field backwards compatibility", () => { it("should support serverInfo with title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new Server( - { + { name: "test-server", version: "1.0.0", title: "Test Server Display Name" }, { capabilities: {} } ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.connect(serverTransport); await client.connect(clientTransport); - + const serverInfo = client.getServerVersion(); expect(serverInfo?.name).toBe("test-server"); expect(serverInfo?.version).toBe("1.0.0"); From 8719f933571e5ba8e0f5b1dc47fb901cb84d9a48 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:00:20 +0100 Subject: [PATCH 100/194] clean up mcp.ts --- src/server/mcp.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7394ecbf..ac6cf772 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -114,6 +114,7 @@ export class McpServer { ([name, tool]): Tool => { const toolDefinition: Tool = { name, + title: tool.title, description: tool.description, inputSchema: tool.inputSchema ? (zodToJsonSchema(tool.inputSchema, { @@ -123,10 +124,6 @@ export class McpServer { annotations: tool.annotations, }; - if (tool.title !== undefined) { - toolDefinition.title = tool.title; - } - if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -472,19 +469,14 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - const promptDefinition: Prompt = { + return { name, + title: prompt.title, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; - - if (prompt.title !== undefined) { - promptDefinition.title = prompt.title; - } - - return promptDefinition; }, ), }), From 3f939be6c7336af0fd91e3fd4ded20a4d0d02a57 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:21:07 +0100 Subject: [PATCH 101/194] readme fixes and template tests for title --- README.md | 12 +++++------ src/server/title.test.ts | 44 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5ae9e043..c753e3b1 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ server.registerResource( title: "Greeting Resource", // Display name for UI description: "Dynamic greeting generator" }, - async (uri, params) => ({ + async (uri, { name }) => ({ contents: [{ uri: uri.href, - text: `Hello, ${params.name}!` + text: `Hello, ${name}!` }] }) ); @@ -143,10 +143,10 @@ server.registerResource( title: "User Profile", description: "User profile information" }, - async (uri, params) => ({ + async (uri, { userId }) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${params.userId}` + text: `Profile data for user ${userId}` }] }) ); @@ -479,10 +479,10 @@ server.registerResource( title: "Echo Resource", description: "Echoes back messages as resources" }, - async (uri, params) => ({ + async (uri, { message }) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${params.message}` + text: `Resource echo: ${message}` }] }) ); diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 02bafe12..480257cd 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -2,7 +2,7 @@ import { Server } from "./index.js"; import { Client } from "../client/index.js"; import { InMemoryTransport } from "../inMemory.js"; import { z } from "zod"; -import { McpServer } from "./mcp.js"; +import { McpServer, ResourceTemplate } from "./mcp.js"; describe("Title field backwards compatibility", () => { it("should work with tools that have title", async () => { @@ -169,6 +169,48 @@ describe("Title field backwards compatibility", () => { expect(resources.resources[0].mimeType).toBe("text/plain"); }); + it("should work with dynamic resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register dynamic resource with title using registerResource + server.registerResource( + "user-profile", + new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, + async (uri, { userId }, extra) => ({ + contents: [{ + uri: uri.href, + text: `Profile data for user ${userId}` + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile"); + expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile"); + expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information"); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile"); + + // Test reading the resource + const readResult = await client.readResource({ uri: "users://123/profile" }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents[0].text).toBe("Profile data for user 123"); + }); + it("should support serverInfo with title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); From e78c1b787d663c17d3e122d9372d96998f268be7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:27:34 +0100 Subject: [PATCH 102/194] fix --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c753e3b1..0170b5fe 100644 --- a/README.md +++ b/README.md @@ -530,8 +530,7 @@ import { z } from "zod"; const server = new McpServer({ name: "sqlite-explorer", - version: "1.0.0", - title: "SQLite Explorer" + version: "1.0.0" }); // Helper to create DB connection From 90e45950b3c0100469a340b661731f929c02298e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:36:08 +0100 Subject: [PATCH 103/194] show name and title in example --- src/examples/client/simpleStreamableHttp.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index efa176df..e7232fe0 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,12 +318,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - const displayName = getDisplayName(tool); - if (displayName !== tool.name) { - console.log(` - ${tool.name} (${displayName}): ${tool.description}`); - } else { - console.log(` - ${tool.name}: ${tool.description}`); - } + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); } } } catch (error) { @@ -386,7 +381,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num try { console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`); console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`); - + const request: CallToolRequest = { method: 'tools/call', params: { @@ -399,7 +394,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num notificationsToolLastEventId = event; console.log(`Updated resumption token: ${event}`); }; - + const result = await client.request(request, CallToolResultSchema, { resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate @@ -435,7 +430,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${getDisplayName(prompt)}: ${prompt.description}`); + console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`); } } } catch (error) { @@ -486,7 +481,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${getDisplayName(resource)}: ${resource.uri}`); + console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`); } } } catch (error) { From f1a07a940a13b79da8d2ac3b13ba7f2590de65d5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:45:35 +0100 Subject: [PATCH 104/194] ci fix --- src/server/title.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 480257cd..3f64570b 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -185,7 +185,7 @@ describe("Title field backwards compatibility", () => { title: "User Profile", description: "User profile information" }, - async (uri, { userId }, extra) => ({ + async (uri, { userId }, _extra) => ({ contents: [{ uri: uri.href, text: `Profile data for user ${userId}` From 97519d3e32472410b04d8e20a4102f778c5d82aa Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:21 +0100 Subject: [PATCH 105/194] add description to resourse link --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0cf2b85e..ff6c482f 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,18 @@ server.registerTool( { type: "text", text: `Found files matching "${pattern}":` }, // ResourceLinks let tools return references without file content { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/README.md", name: "README.md", - mimeType: "text/markdown" + mimeType: "text/markdown", + description: 'A README file' }, { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/src/index.ts", name: "index.ts", - mimeType: "text/typescript" + mimeType: "text/typescript", + description: 'An index file' } ] }) From 1e5f0e16ba4801d7c5cfa8227b2becdff2ec6571 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:59 +0100 Subject: [PATCH 106/194] whitespaces --- src/types.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/types.test.ts b/src/types.test.ts index ca49867a..d163f03d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,5 +1,5 @@ -import { - LATEST_PROTOCOL_VERSION, +import { + LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, ResourceLinkSchema, ContentBlockSchema, @@ -28,7 +28,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -48,7 +48,7 @@ describe("Types", () => { mimeType: "text/plain", _meta: { custom: "metadata" } }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -65,7 +65,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -76,7 +76,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt" // missing name }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -88,7 +88,7 @@ describe("Types", () => { type: "text", text: "Hello, world!" }; - + const result = ContentBlockSchema.safeParse(textContent); expect(result.success).toBe(true); if (result.success) { @@ -102,7 +102,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "image/png" }; - + const result = ContentBlockSchema.safeParse(imageContent); expect(result.success).toBe(true); if (result.success) { @@ -116,7 +116,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "audio/mp3" }; - + const result = ContentBlockSchema.safeParse(audioContent); expect(result.success).toBe(true); if (result.success) { @@ -131,7 +131,7 @@ describe("Types", () => { name: "file.txt", mimeType: "text/plain" }; - + const result = ContentBlockSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -148,7 +148,7 @@ describe("Types", () => { text: "File contents" } }; - + const result = ContentBlockSchema.safeParse(embeddedResource); expect(result.success).toBe(true); if (result.success) { @@ -169,7 +169,7 @@ describe("Types", () => { mimeType: "text/x-rust" } }; - + const result = PromptMessageSchema.safeParse(promptMessage); expect(result.success).toBe(true); if (result.success) { @@ -202,7 +202,7 @@ describe("Types", () => { } ] }; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { @@ -215,7 +215,7 @@ describe("Types", () => { test("should validate empty content array with default", () => { const toolResult = {}; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { From c90d6c9853ab64cf94c2d3d016f615ae4376fa89 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:11:10 +0100 Subject: [PATCH 107/194] add context with arguments to completable --- src/server/completable.ts | 3 + src/server/mcp.test.ts | 271 +++++++++++++++++++++++++++++++++++--- src/server/mcp.ts | 7 +- src/types.test.ts | 90 ++++++++++++- src/types.ts | 8 ++ 5 files changed, 357 insertions(+), 22 deletions(-) diff --git a/src/server/completable.ts b/src/server/completable.ts index 3b5bc164..652eaf72 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -15,6 +15,9 @@ export enum McpZodTypeKind { export type CompleteCallback = ( value: T["_input"], + context?: { + arguments?: Record; + }, ) => T["_input"][] | Promise; export interface CompletableDef diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 36a8f7b8..23600db4 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3521,12 +3521,12 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(2); - + // Resource 1 should have its own metadata expect(result.resources[0].name).toBe("Resource 1"); expect(result.resources[0].description).toBe("Individual resource description"); expect(result.resources[0].mimeType).toBe("text/plain"); - + // Resource 2 should inherit template metadata expect(result.resources[1].name).toBe("Resource 2"); expect(result.resources[1].description).toBe("Template description"); @@ -3592,7 +3592,7 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(1); - + // All fields should be from the individual resource, not the template expect(result.resources[0].name).toBe("Overridden Name"); expect(result.resources[0].description).toBe("Overridden description"); @@ -3698,41 +3698,274 @@ describe("Tool title precedence", () => { }); test("getDisplayName unit tests for title precedence", () => { - + // Test 1: Only name expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); - + // Test 2: Name and title - title wins - expect(getDisplayName({ - name: "tool_name", - title: "Tool Title" + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" })).toBe("Tool Title"); - + // Test 3: Name and annotations.title - annotations.title wins - expect(getDisplayName({ + expect(getDisplayName({ name: "tool_name", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 4: All three - title wins (correct precedence) - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "Regular Title", annotations: { title: "Annotations Title" } })).toBe("Regular Title"); - + // Test 5: Empty title should not be used - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 6: Undefined vs null handling - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: undefined, annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); }); + + test("should support resource template completion with resolved context", 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("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } else if (context?.arguments?.["owner"] === "org2") { + return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value)); + } + return []; + }, + }, + }), + async () => ({ + contents: [ + { + uri: "github://repos/test/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with microsoft owner + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "p", + }, + context: { + arguments: { + owner: "org1", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["project1", "project2", "project3"]); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "r", + }, + context: { + arguments: { + owner: "org2", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "t", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test("should support prompt argument completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["category"] === "developers") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["category"] === "managers") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + }, + 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), + ]); + + // Test with developers category + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "A", + }, + context: { + arguments: { + category: "developers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["Alice"]); + + // Test with managers category + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "D", + }, + context: { + arguments: { + category: "managers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["David"]); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Guest"]); + }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index ac6cf772..3d9673da 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -293,7 +293,7 @@ export class McpServer { } const def: CompletableDef = field._def; - const suggestions = await def.complete(request.params.argument.value); + const suggestions = await def.complete(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -324,7 +324,7 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -1068,6 +1068,9 @@ export class McpServer { */ export type CompleteResourceTemplateCallback = ( value: string, + context?: { + arguments?: Record; + }, ) => string[] | Promise; /** diff --git a/src/types.test.ts b/src/types.test.ts index d163f03d..bc109110 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,8 @@ import { ResourceLinkSchema, ContentBlockSchema, PromptMessageSchema, - CallToolResultSchema + CallToolResultSchema, + CompleteRequestSchema } from "./types.js"; describe("Types", () => { @@ -223,4 +224,91 @@ describe("Types", () => { } }); }); + + describe("CompleteRequest", () => { + test("should validate a CompleteRequest without resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "greeting" }, + argument: { name: "name", value: "A" } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe("completion/complete"); + expect(result.data.params.ref.type).toBe("ref/prompt"); + expect(result.data.params.context).toBeUndefined(); + } + }); + + test("should validate a CompleteRequest with resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" }, + argument: { name: "repo", value: "t" }, + context: { + arguments: { + "{owner}": "microsoft" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{owner}": "microsoft" + }); + } + }); + + test("should validate a CompleteRequest with empty resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + context: { + arguments: {} + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({}); + } + }); + + test("should validate a CompleteRequest with multiple resolved variables", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" }, + argument: { name: "id", value: "123" }, + context: { + arguments: { + "{tenant}": "acme-corp", + "{resource}": "users" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{tenant}": "acme-corp", + "{resource}": "users" + }); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 8e8b7d33..1bc22591 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1169,6 +1169,14 @@ export const CompleteRequestSchema = RequestSchema.extend({ value: z.string(), }) .passthrough(), + context: z.optional( + z.object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.optional(z.record(z.string(), z.string())), + }) + ), }), }); From 42c3967d8b04f2a95550102967f65763719fceb5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:23:17 +0100 Subject: [PATCH 108/194] add to readme --- README.md | 111 ++++++++++++++++++++++++++++++++++++++++- src/server/mcp.test.ts | 28 +++++++---- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ff6c482f..63acfb06 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,33 @@ server.registerResource( }] }) ); + +// Resource with context-aware completion +server.registerResource( + "repository", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + // Provide intelligent completions based on previously resolved parameters + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } + return ["default-repo"].filter(r => r.startsWith(value)); + } + } + }), + { + title: "GitHub Repository", + description: "Repository information" + }, + async (uri, { owner, repo }) => ({ + contents: [{ + uri: uri.href, + text: `Repository: ${owner}/${repo}` + }] + }) +); ``` ### Tools @@ -233,12 +260,14 @@ Tools can return `ResourceLink` objects to reference resources without embedding Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + server.registerPrompt( "review-code", { title: "Code Review", description: "Review code for best practices and potential issues", - arguments: { code: z.string() } + argsSchema: { code: z.string() } }, ({ code }) => ({ messages: [{ @@ -250,6 +279,35 @@ server.registerPrompt( }] }) ); + +// Prompt with context-aware completion +server.registerPrompt( + "team-greeting", + { + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + // Completable arguments can use context for intelligent suggestions + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["department"] === "engineering") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["department"] === "sales") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }) + } + }, + ({ name }) => ({ + messages: [{ + role: "assistant", + content: { + type: "text", + text: `Hello ${name}, welcome to the team!` + } + }] + }) +); ``` ### Display Names and Metadata @@ -637,6 +695,57 @@ server.registerTool( ## Advanced Usage +### Context-Aware Completions + +MCP supports intelligent completions that can use previously resolved values as context. This is useful for creating dependent parameter completions where later parameters depend on earlier ones: + +```typescript +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +// For resource templates +server.registerResource( + "database-query", + new ResourceTemplate("db://{database}/{table}/{query}", { + list: undefined, + complete: { + // Table completions depend on the selected database + table: (value, context) => { + const database = context?.arguments?.["database"]; + if (database === "users_db") { + return ["profiles", "sessions", "preferences"].filter(t => t.startsWith(value)); + } else if (database === "products_db") { + return ["items", "categories", "inventory"].filter(t => t.startsWith(value)); + } + return []; + } + } + }), + metadata, + handler +); + +// For prompts with completable arguments +server.registerPrompt( + "api-request", + { + argsSchema: { + endpoint: z.string(), + // Method completions can be context-aware + method: completable(z.string(), (value, context) => { + const endpoint = context?.arguments?.["endpoint"]; + if (endpoint?.includes("/readonly/")) { + return ["GET"].filter(m => m.startsWith(value.toUpperCase())); + } + return ["GET", "POST", "PUT", "DELETE"].filter(m => m.startsWith(value.toUpperCase())); + }) + } + }, + handler +); +``` + +The context object contains an `arguments` field with previously resolved parameter values, allowing you to provide more intelligent and contextual completions. + ### Dynamic Servers If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 23600db4..685987bf 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3747,7 +3747,7 @@ describe("Tool title precedence", () => { version: "1.0", }); - mcpServer.resource( + mcpServer.registerResource( "test", new ResourceTemplate("github://repos/{owner}/{repo}", { list: undefined, @@ -3762,6 +3762,10 @@ describe("Tool title precedence", () => { }, }, }), + { + title: "GitHub Repository", + description: "Repository information" + }, async () => ({ contents: [ { @@ -3865,17 +3869,21 @@ describe("Tool title precedence", () => { version: "1.0", }); - mcpServer.prompt( + mcpServer.registerPrompt( "test-prompt", { - name: completable(z.string(), (value, context) => { - if (context?.arguments?.["category"] === "developers") { - return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["category"] === "managers") { - return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); - } - return ["Guest"].filter(n => n.startsWith(value)); - }), + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["category"] === "developers") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["category"] === "managers") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + } }, async ({ name }) => ({ messages: [ From 0e86a6d139e0a3da564101cb751f53d3c3fb00fe Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:36:35 +0100 Subject: [PATCH 109/194] improve readme --- README.md | 68 ++++++++---------------------------------- src/server/mcp.test.ts | 51 ++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 63acfb06..08b711c2 100644 --- a/README.md +++ b/README.md @@ -287,23 +287,30 @@ server.registerPrompt( title: "Team Greeting", description: "Generate a greeting for team members", argsSchema: { - // Completable arguments can use context for intelligent suggestions + department: completable(z.string(), (value) => { + // Department suggestions + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), name: completable(z.string(), (value, context) => { - if (context?.arguments?.["department"] === "engineering") { + // Name suggestions based on selected department + const department = context?.arguments?.["department"]; + if (department === "engineering") { return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["department"] === "sales") { + } else if (department === "sales") { return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); } return ["Guest"].filter(n => n.startsWith(value)); }) } }, - ({ name }) => ({ + ({ department, name }) => ({ messages: [{ role: "assistant", content: { type: "text", - text: `Hello ${name}, welcome to the team!` + text: `Hello ${name}, welcome to the ${department} team!` } }] }) @@ -695,57 +702,6 @@ server.registerTool( ## Advanced Usage -### Context-Aware Completions - -MCP supports intelligent completions that can use previously resolved values as context. This is useful for creating dependent parameter completions where later parameters depend on earlier ones: - -```typescript -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; - -// For resource templates -server.registerResource( - "database-query", - new ResourceTemplate("db://{database}/{table}/{query}", { - list: undefined, - complete: { - // Table completions depend on the selected database - table: (value, context) => { - const database = context?.arguments?.["database"]; - if (database === "users_db") { - return ["profiles", "sessions", "preferences"].filter(t => t.startsWith(value)); - } else if (database === "products_db") { - return ["items", "categories", "inventory"].filter(t => t.startsWith(value)); - } - return []; - } - } - }), - metadata, - handler -); - -// For prompts with completable arguments -server.registerPrompt( - "api-request", - { - argsSchema: { - endpoint: z.string(), - // Method completions can be context-aware - method: completable(z.string(), (value, context) => { - const endpoint = context?.arguments?.["endpoint"]; - if (endpoint?.includes("/readonly/")) { - return ["GET"].filter(m => m.startsWith(value.toUpperCase())); - } - return ["GET", "POST", "PUT", "DELETE"].filter(m => m.startsWith(value.toUpperCase())); - }) - } - }, - handler -); -``` - -The context object contains an `arguments` field with previously resolved parameter values, allowing you to provide more intelligent and contextual completions. - ### Dynamic Servers If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 685987bf..15be3d98 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3875,23 +3875,29 @@ describe("Tool title precedence", () => { title: "Team Greeting", description: "Generate a greeting for team members", argsSchema: { + department: completable(z.string(), (value) => { + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), name: completable(z.string(), (value, context) => { - if (context?.arguments?.["category"] === "developers") { + const department = context?.arguments?.["department"]; + if (department === "engineering") { return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["category"] === "managers") { + } else if (department === "sales") { return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); } return ["Guest"].filter(n => n.startsWith(value)); }), } }, - async ({ name }) => ({ + async ({ department, name }) => ({ messages: [ { role: "assistant", content: { type: "text", - text: `Hello ${name}`, + text: `Hello ${name}, welcome to the ${department} team!`, }, }, ], @@ -3906,7 +3912,7 @@ describe("Tool title precedence", () => { mcpServer.server.connect(serverTransport), ]); - // Test with developers category + // Test with engineering department const result1 = await client.request( { method: "completion/complete", @@ -3921,7 +3927,7 @@ describe("Tool title precedence", () => { }, context: { arguments: { - category: "developers", + department: "engineering", }, }, }, @@ -3931,7 +3937,7 @@ describe("Tool title precedence", () => { expect(result1.completion.values).toEqual(["Alice"]); - // Test with managers category + // Test with sales department const result2 = await client.request( { method: "completion/complete", @@ -3946,7 +3952,7 @@ describe("Tool title precedence", () => { }, context: { arguments: { - category: "managers", + department: "sales", }, }, }, @@ -3956,8 +3962,33 @@ describe("Tool title precedence", () => { expect(result2.completion.values).toEqual(["David"]); - // Test with no resolved context + // Test with marketing department const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + context: { + arguments: { + department: "marketing", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Grace"]); + + // Test with no resolved context + const result4 = await client.request( { method: "completion/complete", params: { @@ -3974,6 +4005,6 @@ describe("Tool title precedence", () => { CompleteResultSchema, ); - expect(result3.completion.values).toEqual(["Guest"]); + expect(result4.completion.values).toEqual(["Guest"]); }); }); From c0c78ad412c36403ec70fae28f767dd0a756eaa5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:04:23 +0100 Subject: [PATCH 110/194] add how to use Completions to the readme --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 08b711c2..8d319f67 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,32 @@ server.registerPrompt( ); ``` +### Completions + +MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts). + +#### Client Usage + +```typescript +// Request completions for any argument +const result = await client.complete({ + ref: { + type: "ref/prompt", // or "ref/resource" + name: "example" // or uri: "template://..." + }, + argument: { + name: "argumentName", + value: "partial" // What the user has typed so far + }, + context: { // Optional: Include previously resolved arguments + arguments: { + previousArg: "value" + } + } +}); +console.log(result.completion.values); // ["suggestion1", "suggestion2", ...] +``` + ### Display Names and Metadata All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. @@ -870,6 +896,18 @@ const result = await client.callTool({ arg1: "value" } }); + +// Request completions +const completions = await client.complete({ + ref: { + type: "ref/prompt", + name: "example-prompt" + }, + argument: { + name: "arg1", + value: "partial" + } +}); ``` ### Proxy Authorization Requests Upstream From 4549250a4301ac2f44b60923e38ef2db1f8b7051 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:04:33 +0100 Subject: [PATCH 111/194] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d319f67..a73d8a38 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ const result = await client.complete({ } } }); -console.log(result.completion.values); // ["suggestion1", "suggestion2", ...] + ``` ### Display Names and Metadata From 5b875aa542337f253d9a31b166f068ed519a724b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:19:21 +0100 Subject: [PATCH 112/194] clean up --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index a73d8a38..272eeb12 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Resources](#resources) - [Tools](#tools) - [Prompts](#prompts) + - [Completions](#completions) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -897,17 +898,6 @@ const result = await client.callTool({ } }); -// Request completions -const completions = await client.complete({ - ref: { - type: "ref/prompt", - name: "example-prompt" - }, - argument: { - name: "arg1", - value: "partial" - } -}); ``` ### Proxy Authorization Requests Upstream From 3df732545f0f0d38c13181b6db1381e0eafff41d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Jun 2025 16:06:13 +0100 Subject: [PATCH 113/194] Add missing _meta fields to match spec requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added optional _meta field to ResourceContentsSchema, PromptSchema, and EmbeddedResourceSchema to align with spec change PR #710. These were the only schemas missing the _meta field that were required by the specification update. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/types.ts b/src/types.ts index 1bc22591..1c248d77 100644 --- a/src/types.ts +++ b/src/types.ts @@ -435,6 +435,10 @@ export const ResourceContentsSchema = z * The MIME type of this resource, if known. */ mimeType: z.optional(z.string()), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -637,6 +641,10 @@ export const PromptSchema = BaseMetadataSchema.extend({ * A list of arguments to use for templating the prompt. */ arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }); /** @@ -739,6 +747,10 @@ export const EmbeddedResourceSchema = z .object({ type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); From 4af2d4fe9fbcd983262e248cc18e2102887d8cce Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 19:14:52 +0100 Subject: [PATCH 114/194] separate code block in README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ab5a898..9fa07de3 100644 --- a/README.md +++ b/README.md @@ -923,10 +923,11 @@ server.tool( }; } ); +``` -// Client-side: Handle elicitation requests - +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"; From 7b5bfcee529bc98a1f1cf668b90167855bc28ca0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 19:29:04 +0100 Subject: [PATCH 115/194] 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 1b9fb071..9a067405 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 7a91eb25..9a9965f6 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 5aa99abb..6c07cf25 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 4117bb1b..e452972b 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 00000000..49c6f69b --- /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 024208d6..66583e49 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 00000000..880b9539 --- /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 00000000..e88359bc --- /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 00000000..5ff140d6 --- /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 428199ce..5c001bcd 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 00000000..aa180c4b --- /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 e921d5ea..20a2af89 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 3e9a336b..dbed1b52 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 c165fe7f..68794c36 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 eadbd751..37950502 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 7815b713..25698416 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 69039c3e..b652390b 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 db7460e5..7f8b8d3d 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 c25c2b60..33ba3f86 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 00000000..1c45511a --- /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 00000000..aed5f247 --- /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 784d94437764dc0f8e46ccebab62337202b31a48 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 16 Jun 2025 14:52:33 -0400 Subject: [PATCH 116/194] update _meta comments --- src/types.ts | 54 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/types.ts b/src/types.ts index 1c248d77..762708f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,9 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -58,8 +59,9 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -436,7 +438,8 @@ export const ResourceContentsSchema = z */ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -478,8 +481,9 @@ export const ResourceSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -505,8 +509,9 @@ export const ResourceTemplateSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -642,7 +647,8 @@ export const PromptSchema = BaseMetadataSchema.extend({ */ arguments: z.optional(z.array(PromptArgumentSchema)), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }); @@ -690,8 +696,9 @@ export const TextContentSchema = z text: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -712,8 +719,9 @@ export const ImageContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -734,8 +742,9 @@ export const AudioContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -748,7 +757,8 @@ export const EmbeddedResourceSchema = z type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -895,8 +905,9 @@ export const ToolSchema = BaseMetadataSchema.extend({ annotations: z.optional(ToolAnnotationsSchema), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -1230,8 +1241,9 @@ export const RootSchema = z name: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); From 1f4e42c09ea8c42db79afa63d2abea74a57473a1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 20:05:02 +0100 Subject: [PATCH 117/194] 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 9a9965f6..28188b7c 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 6c07cf25..c484bde9 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 e452972b..25c41bf3 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 880b9539..00000000 --- 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 e88359bc..00000000 --- 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 5ff140d6..00000000 --- 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 dbed1b52..946c46c9 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 37950502..7af42d7a 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 1c45511a..b9571408 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 aed5f247..e69d821d 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 118/194] 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 aa180c4b..f0736da2 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 119/194] 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 49c6f69b..852f0c98 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 66583e49..2f0e3539 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 ebe31920..65b6263e 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 f0736da2..00000000 --- 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 946c46c9..f6c862ac 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 7af42d7a..92fe9921 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 33ba3f86..c25c2b60 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 120/194] In src/examples/server/simpleStreamableHttp.ts - In mcpPostHandler, - Get `mcp-session-id` header early so that it can be reported in every incoming request. - Helpful for troubleshooting Inspector's ability to retain the session id for the Proxy <-> Server leg --- src/examples/server/simpleStreamableHttp.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920..dfc1f197 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -178,10 +178,10 @@ const getServer = () => { server.registerResource( 'example-file-1', 'file:///example/file1.txt', - { + { title: 'Example File 1', description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -198,10 +198,10 @@ const getServer = () => { server.registerResource( 'example-file-2', 'file:///example/file2.txt', - { + { title: 'Example File 2', description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -338,15 +338,13 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body); if (useOAuth && req.auth) { console.log('Authenticated user:', req.auth); } try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; From e542ec1989636987867ea082adb0e0b8eab88c91 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 21:38:00 +0100 Subject: [PATCH 121/194] 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 00000000..9a43ac7c --- /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 00000000..b4e48cbd --- /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 122/194] Simplify demo in-memory oauth provider Co-Authored-By: Claude --- .../server/demoInMemoryOAuthProvider.ts | 162 ++++-------------- src/server/auth/types.ts | 6 + 2 files changed, 40 insertions(+), 128 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 2f0e3539..3672c3e0 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,7 +156,30 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _scopes?: string[], resource?: string ): Promise { - // Validate resource parameter based on configuration + throw new Error('Refresh tokens not implemented for example demo'); + } + + async verifyAccessToken(token: string): Promise { + const tokenData = this.tokens.get(token); + 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, + }; + } + + /** + * 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"); @@ -248,54 +194,14 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } } 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'); - } - - async verifyAccessToken(token: string): Promise { - const tokenData = this.tokens.get(token); - if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { - throw new Error('Invalid or expired token'); - } - - return { - token, - clientId: tokenData.clientId, - scopes: tokenData.scopes, - 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}` - ); + 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 c25c2b60..bf1a257b 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 123/194] simplify diff --- .../server/demoInMemoryOAuthProvider.ts | 80 +++---------------- 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, 19 insertions(+), 104 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 3672c3e0..5de0fb90 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; + private validateResource?: (resource?: URL) => boolean; - constructor(config?: DemoOAuthProviderConfig) { - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + 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 5c001bcd..428199ce 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 25698416..93a56a09 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 bf1a257b..0189e9ed 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 e69d821d..086d812f 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 124/194] 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 5de0fb90..9fcb2517 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 125/194] 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 28188b7c..e465ea3b 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 9fcb2517..316d1f8a 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 20a2af89..2742d1e5 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 f6c862ac..17c88b45 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 93a56a09..18beb216 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 b652390b..75dc1a15 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 7f8b8d3d..4c807444 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 b9571408..c35bb122 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 126/194] 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 9a067405..44516130 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 c484bde9..41f21de6 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 25c41bf3..3534fb45 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 68794c36..63b47f53 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 92fe9921..3ffd4cf2 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 75dc1a15..b834c659 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 4c807444..de74862b 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 127/194] Update demoInMemoryOAuthProvider.test.ts --- .../server/demoInMemoryOAuthProvider.test.ts | 385 +++++------------- 1 file changed, 98 insertions(+), 287 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 852f0c98..9bdcfdfa 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 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 allow authorization when resource is in allowed list', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2']; + 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.example.com/v1', + state: 'test-state', 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']; - 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); + 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 + 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: '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', + 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'); + expect(tokenDetails?.resource).toEqual(new URL('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']; + 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', - undefined, - 'https://api.forbidden.com' - )).rejects.toThrow(InvalidTargetError); + 'refresh-token' + )).rejects.toThrow('Refresh tokens not implemented for example demo'); }); }); - describe('Allowed resources management', () => { - it('should update allowed resources for a client', async () => { - await clientsStore.registerClient(mockClient); + describe('Server URL validation', () => { + it('should accept mcpServerUrl configuration', () => { + const serverUrl = new URL('https://api.example.com/mcp'); + const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); - // 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); - }); - }); - - 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"); + expect(providerWithUrl).toBeDefined(); }); - 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') - ); - }); + 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 8b9aa20b390699ef851863e28c1ef7a52adbe2ec Mon Sep 17 00:00:00 2001 From: Jerome Date: Tue, 17 Jun 2025 10:18:58 +0100 Subject: [PATCH 128/194] Jerome/fix/dev oauth server required scopes change (#629) * updating lock to latest version * Added .git-blame-ignore-revs (fixes gitlens) * Not requiring mcp:tools --- .git-blame-ignore-revs | 0 package-lock.json | 4 ++-- src/examples/server/simpleStreamableHttp.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b diff --git a/package-lock.json b/package-lock.json index 40bad9fe..c676d07f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.11.4", + "version": "1.12.3", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 52c49caf..4f424151 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -477,7 +477,7 @@ if (useOAuth) { authMiddleware = requireBearerAuth({ verifier: tokenVerifier, - requiredScopes: ['mcp:tools'], + requiredScopes: [], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); } From ec0c50425ef4119f9547fcafc16f2eda95664956 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 10:59:36 +0100 Subject: [PATCH 129/194] 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 9a43ac7c..00000000 --- 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 b4e48cbd..00000000 --- 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 130/194] 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 44516130..2cd9a2d1 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 e465ea3b..681cde99 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 41f21de6..7a500c6b 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 3534fb45..85a0ad10 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 316d1f8a..d6a64398 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 65b6263e..da5e740a 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 17c88b45..0a6283a8 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 3ffd4cf2..1d97805b 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 131/194] 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 2cd9a2d1..9ee4e6cf 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 9bdcfdfa..e3a47813 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 4f708a777f5bfd81bd590a4d647b310038beb551 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 17 Jun 2025 11:38:00 +0100 Subject: [PATCH 132/194] update latest protocol version --- src/client/auth.test.ts | 3 ++- .../stateManagementStreamableHttp.test.ts | 14 +++++++------- src/types.test.ts | 3 ++- src/types.ts | 3 ++- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 1b9fb071..8f415c17 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1,3 +1,4 @@ +import { LATEST_PROTOCOL_VERSION } from '../types.js'; import { discoverOAuthMetadata, startAuthorization, @@ -202,7 +203,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-03-26" + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION }); }); diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index d12a4f99..4a191134 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; import { z } from 'zod'; describe('Streamable HTTP Transport Session Management', () => { @@ -145,7 +145,7 @@ describe('Streamable HTTP Transport Session Management', () => { params: {} }, ListToolsResultSchema); - + }); it('should operate without session management', async () => { // Create and connect a client @@ -220,15 +220,15 @@ describe('Streamable HTTP Transport Session Management', () => { }); const transport = new StreamableHTTPClientTransport(baseUrl); - + // Verify protocol version is not set before connecting expect(transport.protocolVersion).toBeUndefined(); - + await client.connect(transport); - + // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + // Clean up await transport.close(); }); diff --git a/src/types.test.ts b/src/types.test.ts index bc109110..0115f2ed 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -12,7 +12,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-06-17"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); @@ -20,6 +20,7 @@ describe("Types", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-11-05"); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2025-03-26"); }); describe("ResourceLink", () => { diff --git a/src/types.ts b/src/types.ts index 1f7f6fa0..a6f59623 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const LATEST_PROTOCOL_VERSION = "2025-06-17"; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2025-03-26", "2024-11-05", "2024-10-07", ]; From 515abb492fc722bc5925d777685147d5fc746580 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:53:26 +0100 Subject: [PATCH 133/194] 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 7a500c6b..0a238d98 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 85a0ad10..c810588f 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 d6a64398..3133e455 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 741a201a3d4bff3e98bb1003b444414f9602100e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 17 Jun 2025 12:35:29 +0100 Subject: [PATCH 134/194] remove hasCustomTitle --- src/shared/metadataUtils.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index 410827a5..0119a669 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -15,7 +15,7 @@ export function getDisplayName(metadata: BaseMetadata): string { if (metadata.title !== undefined && metadata.title !== '') { return metadata.title; } - + // Then check for annotations.title (only present in Tool objects) if ('annotations' in metadata) { const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; @@ -23,14 +23,7 @@ export function getDisplayName(metadata: BaseMetadata): string { return metadataWithAnnotations.annotations.title; } } - + // Finally fall back to name return metadata.name; } - -/** - * Checks if an object has a custom title different from its name. - */ -export function hasCustomTitle(metadata: BaseMetadata): boolean { - return metadata.title !== undefined && metadata.title !== metadata.name; -} \ No newline at end of file From 40f61d88a0e02f0e710cf19c053c562bf47de54f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 17:05:34 +0100 Subject: [PATCH 135/194] 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 3133e455..500f59b8 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 da5e740a..a97fdf5a 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 b8953e5c..cae054d5 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 fd96055a..4674089f 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 18beb216..409e9dae 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 136/194] 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 cae054d5..665ef926 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 66465abdd91ac7c72c8397358cf4adbf4284fd33 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 17 Jun 2025 17:29:41 +0100 Subject: [PATCH 137/194] change version --- src/types.test.ts | 2 +- src/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types.test.ts b/src/types.test.ts index 0115f2ed..0aee62a9 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -12,7 +12,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-06-17"); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-06-18"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); diff --git a/src/types.ts b/src/types.ts index a6f59623..e16b313d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-06-17"; +export const LATEST_PROTOCOL_VERSION = "2025-06-18"; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, From c5922f8765c17e3ba070fd43e57b12071306623c Mon Sep 17 00:00:00 2001 From: David Dworken Date: Tue, 17 Jun 2025 10:07:59 -0700 Subject: [PATCH 138/194] Fix node 18 incompatibility caused by race condition with closing server by using a newly allocated port for each test --- src/server/streamableHttp.test.ts | 36 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index d36bb8f2..adbc9025 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,5 +1,5 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; -import { AddressInfo } from "node:net"; +import { createServer as netCreateServer, AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; @@ -7,6 +7,20 @@ import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; import { AuthInfo } from "./auth/types.js"; +async function getFreePort() { + return new Promise( res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()! + if (typeof address === "string") { + throw new Error("Unexpected address type: " + typeof address); + } + const port = (address as AddressInfo).port; + srv.close((err) => res(port)) + }); + }) +} + /** * Test server configuration for StreamableHTTPServerTransport tests */ @@ -1441,7 +1455,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should accept requests with allowed host headers", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], enableDnsRebindingProtection: true, }); server = result.server; @@ -1563,7 +1577,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should skip all validations when enableDnsRebindingProtection is false", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3000'], enableDnsRebindingProtection: false, }); @@ -1591,7 +1605,7 @@ describe("StreamableHTTPServerTransport DNS rebinding protection", () => { it("should validate both host and origin when both are configured", async () => { const result = await createTestServerWithDnsProtection({ sessionIdGenerator: undefined, - allowedHosts: ['localhost:3001'], + allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3001'], enableDnsRebindingProtection: true, }); @@ -1649,6 +1663,17 @@ async function createTestServerWithDnsProtection(config: { { capabilities: { logging: {} } } ); + const port = await getFreePort(); + + if (config.allowedHosts) { + config.allowedHosts = config.allowedHosts.map(host => { + if (host.includes(':')) { + return host; + } + return `localhost:${port}`; + }); + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, allowedHosts: config.allowedHosts, @@ -1672,10 +1697,9 @@ async function createTestServerWithDnsProtection(config: { }); await new Promise((resolve) => { - httpServer.listen(3001, () => resolve()); + httpServer.listen(port, () => resolve()); }); - const port = (httpServer.address() as AddressInfo).port; const serverUrl = new URL(`http://localhost:${port}/`); return { From c2150f0cb0a5cc99ee7cdc314e51f828f5ee34f5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 18:41:37 +0100 Subject: [PATCH 139/194] 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 68e1ece2..c074c757 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 140/194] 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 c074c757..ac92e8de 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 500f59b8..5c34166e 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 a97fdf5a..fdac5357 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 4674089f..a34625d1 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 409e9dae..18beb216 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 141/194] 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 fdac5357..3c7318fd 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 142/194] 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 3c7318fd..9ca48fdb 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 143/194] 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 9ca48fdb..068a0144 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 144/194] 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 0a238d98..5aa99abb 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 c810588f..f64c1ad8 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 145/194] 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 f64c1ad8..4117bb1b 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 146/194] 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 9ee4e6cf..c6d53343 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 147/194] 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 068a0144..9eb87d92 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 148/194] 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 681cde99..5fa2dee2 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 149/194] 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 c6d53343..ec913ecd 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 e3a47813..00000000 --- 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 5c34166e..fe8d3f9c 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 150/194] 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 2742d1e5..438db6a6 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 665ef926..cf1a9359 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 151/194] 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 5fa2dee2..fbe50e11 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 63b47f53..dda4e755 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 152/194] 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 cf1a9359..b8953e5c 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 a34625d1..fd96055a 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 153/194] 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 fbe50e11..297eb9cf 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 154/194] 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 ec913ecd..9cdc9e05 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 155/194] 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 714e1fdd..3cb4e8a3 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 156/194] 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 9cdc9e05..cb726717 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 157/194] shrink tests --- src/server/auth/handlers/token.test.ts | 114 ++---------------- .../auth/providers/proxyProvider.test.ts | 72 +---------- 2 files changed, 11 insertions(+), 175 deletions(-) diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index dda4e755..4b7fae02 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,6 +466,12 @@ 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'); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + new URL('https://api.example.com/resource') // resource parameter + ); }); it('respects requested scopes on refresh', async () => { @@ -497,92 +489,6 @@ 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 - new URL('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 - new URL('https://api.example.com/resource') // resource parameter - ); - }); }); describe('CORS support', () => { diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index b834c659..4e98d0dc 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 158/194] fix: skip validation if tool reports error --- src/server/mcp.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7fb6bd55..242f0529 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1204,6 +1204,72 @@ describe("tool()", () => { ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: "text", + text: `Processed: ${input}`, + }, + ], + isError: true, + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to not throw an error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, + }); + }); + /*** * Test: Schema Validation Failure for Invalid Structured Content */ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da..9440708d 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema) { + if (tool.outputSchema && (result.isError !== true)) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From 4b3db9bbebb1fa93e0e59841a3fc8842996ba43f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 15:06:16 +0100 Subject: [PATCH 159/194] 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 297eb9cf..7097eab0 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 160/194] 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 cb726717..194c1124 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 7097eab0..4d604d28 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 161/194] 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 4d604d28..bef7965f 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 162/194] 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 194c1124..91422de0 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 163/194] 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 bef7965f..28d9d833 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 164/194] 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 9fa07de3..aa8f9304 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 ddb27419..02db131e 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 98d85c94..40e96a44 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 ce54247a..48b7f734 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 7fb6bd55..50df25b5 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 e16b313d..3606a6be 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 165/194] 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 c676d07f..d14ac4f4 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 6b184f31..4516ef29 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 166/194] 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 40e96a44..37c5f0be 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 167/194] add overloads for registerResource method in McpServer class Otherwise, TypeScript can't properly distinguish the type of callback --- src/server/mcp.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da..ee85ab59 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -613,6 +613,18 @@ export class McpServer { * Registers a resource with a config object and callback. * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. */ + registerResource( + name: string, + uriOrTemplate: string, + config: ResourceMetadata, + readCallback: ReadResourceCallback + ): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, From 2a238905df96dda86d84fc6b05971c8e160e8b37 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 19 Jun 2025 11:44:43 +0100 Subject: [PATCH 168/194] simpleStreamableHttp: fix example code (#660) --- .../server/demoInMemoryOAuthProvider.ts | 22 +++++++++---------- src/examples/server/simpleStreamableHttp.ts | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index fe8d3f9c..274a504a 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 37c5f0be..6406bc21 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 169/194] adjust default validation for resource parameter in client flow, and server example --- src/client/auth.test.ts | 2 +- src/client/auth.ts | 14 +++---- src/examples/server/simpleStreamableHttp.ts | 3 +- src/shared/auth-utils.test.ts | 35 +++++++++++++++- src/shared/auth-utils.ts | 44 ++++++++++++++++++++- 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b99e4c90..532e13a3 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1041,7 +1041,7 @@ describe("OAuth Authorization", () => { // Verify custom validation method was called expect(mockValidateResourceURL).toHaveBeenCalledWith( - "https://api.example.com/mcp-server", + new URL("https://api.example.com/mcp-server"), "https://different-resource.example.com/mcp-server" ); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 28d9d833..c97d4f0b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; +import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -198,13 +198,13 @@ export async function auth( } async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + const resource = resourceUrlFromServerUrl(serverUrl); if (provider.validateResourceURL) { - return await provider.validateResourceURL(serverUrl, resourceMetadata?.resource); - } - - const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); - if (resourceMetadata && resourceMetadata.resource !== resource.href) { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); + return await provider.validateResourceURL(resource, resourceMetadata?.resource); + } else if (resourceMetadata) { + if (!checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); + } } return resource; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6406bc21..09d30da2 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -9,6 +9,7 @@ import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDe import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; +import { checkResourceAllowed } from 'src/shared/auth-utils.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); @@ -463,7 +464,7 @@ if (useOAuth) { if (!data.aud) { throw new Error(`Resource Indicator (RFC8707) missing`); } - if (data.aud !== mcpServerUrl.href) { + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); } } diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index c35bb122..c1fa7bdf 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,4 +1,4 @@ -import { resourceUrlFromServerUrl } from './auth-utils.js'; +import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -27,4 +27,35 @@ describe('auth-utils', () => { expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); }); }); -}); \ No newline at end of file + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true); + }); + + it('should not match URLs with different paths', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false); + }); + }); +}); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index 086d812f..97a77c01 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -7,8 +7,48 @@ * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". * Keeps everything else unchanged (scheme, domain, port, path, query). */ -export function resourceUrlFromServerUrl(url: URL): URL { - const resourceURL = new URL(url.href); +export function resourceUrlFromServerUrl(url: URL | string ): URL { + const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href); resourceURL.hash = ''; // Remove fragment return resourceURL; } + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param requestedResource The resource URL being requested + * @param configuredResource The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ + export function checkResourceAllowed( + { requestedResource, configuredResource }: { + requestedResource: URL | string; + configuredResource: URL | string + } + ): boolean { + const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href); + const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); + } From eff548c06f493ffaa3de9d38a33e5b32b0b4e093 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 19 Jun 2025 18:02:00 +0100 Subject: [PATCH 170/194] adjust to provided resource --- src/client/auth.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 9 ++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 532e13a3..f95cb2ca 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1045,5 +1045,66 @@ describe("OAuth Authorization", () => { "https://different-resource.example.com/mcp-server" ); }); + + it("uses prefix of server URL from PRM resource as resource parameter", async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: "https://api.example.com/", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server/endpoint", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter from PRM + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index c97d4f0b..680fefd0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -197,12 +197,15 @@ export async function auth( return "REDIRECT"; } -async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { - const resource = resourceUrlFromServerUrl(serverUrl); +export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + let resource = resourceUrlFromServerUrl(serverUrl); if (provider.validateResourceURL) { return await provider.validateResourceURL(resource, resourceMetadata?.resource); } else if (resourceMetadata) { - if (!checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { + // If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request. + resource = new URL(resourceMetadata.resource); + } else { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); } } From 744b9eade60424709e7a8b0e6741fbd3306af81f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 19 Jun 2025 18:56:25 +0100 Subject: [PATCH 171/194] 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 4516ef29..bb8022fa 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 172/194] feat: remove console statements from SDK code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all console.log, console.warn, and console.error from src/client and src/server - Add ESLint no-console rule for client and server directories (excluding tests) - Keep console statements in test files, examples, and CLI tools as intended Addresses feedback in PR #665 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- eslint.config.mjs | 7 ++++++ src/client/auth.ts | 10 +++----- src/client/index.ts | 4 +-- src/server/auth/handlers/authorize.ts | 2 -- src/server/auth/handlers/register.ts | 1 - src/server/auth/handlers/revoke.ts | 31 ++++++++++++++---------- src/server/auth/handlers/token.ts | 2 -- src/server/auth/middleware/bearerAuth.ts | 1 - src/server/auth/middleware/clientAuth.ts | 1 - 9 files changed, 31 insertions(+), 28 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 515114cf..d792f015 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,5 +15,12 @@ export default tseslint.config( { "argsIgnorePattern": "^_" } ] } + }, + { + files: ["src/client/**/*.ts", "src/server/**/*.ts"], + ignores: ["**/*.test.ts"], + rules: { + "no-console": "error" + } } ); diff --git a/src/client/auth.ts b/src/client/auth.ts index 28d9d833..f84efa05 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -116,8 +116,8 @@ export async function auth( if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) + } catch { + // Ignore errors and fall back to /.well-known/oauth-authorization-server } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); @@ -175,8 +175,8 @@ export async function auth( await provider.saveTokens(newTokens); return "AUTHORIZED"; - } catch (error) { - console.error("Could not refresh OAuth tokens:", error); + } catch { + // Could not refresh OAuth tokens } } @@ -222,7 +222,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { const [type, scheme] = authenticateHeader.split(' '); if (type.toLowerCase() !== 'bearer' || !scheme) { - console.log("Invalid WWW-Authenticate header format, expected 'Bearer'"); return undefined; } const regex = /resource_metadata="([^"]*)"/; @@ -235,7 +234,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { try { return new URL(match[1]); } catch { - console.log("Invalid resource metadata url: ", match[1]); return undefined; } } diff --git a/src/client/index.ts b/src/client/index.ts index f3d440b9..3e8d8ec8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -486,8 +486,8 @@ export class Client< try { const validator = this._ajv.compile(tool.outputSchema); this._cachedToolOutputValidators.set(tool.name, validator); - } catch (error) { - console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`); + } catch { + // Ignore schema compilation errors } } } diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 0a6283a8..126ce006 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -99,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error looking up client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } @@ -146,7 +145,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A if (error instanceof OAuthError) { res.redirect(302, createErrorRedirect(redirect_uri, error, state)); } else { - console.error("Unexpected error during authorization:", error); const serverError = new ServerError("Internal Server Error"); res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); } diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 30b7cdf8..c3137348 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -104,7 +104,6 @@ export function clientRegistrationHandler({ const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error registering client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts index 95e8b4b3..0d1b30e0 100644 --- a/src/server/auth/handlers/revoke.ts +++ b/src/server/auth/handlers/revoke.ts @@ -9,7 +9,7 @@ import { InvalidRequestError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, } from "../errors.js"; export type RevocationHandlerOptions = { @@ -21,7 +21,10 @@ export type RevocationHandlerOptions = { rateLimit?: Partial | false; }; -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { +export function revocationHandler({ + provider, + rateLimit: rateLimitConfig, +}: RevocationHandlerOptions): RequestHandler { if (!provider.revokeToken) { throw new Error("Auth provider does not support revoking tokens"); } @@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo // Apply rate limiting unless explicitly disabled if (rateLimitConfig !== false) { - router.use(rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - })); + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError( + "You have exceeded the rate limit for token revocation requests" + ).toResponseObject(), + ...rateLimitConfig, + }) + ); } // Authenticate and extract client details router.use(authenticateClient({ clientsStore: provider.clientsStore })); router.post("/", async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); + res.setHeader("Cache-Control", "no-store"); try { const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); @@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error revoking token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 1d97805b..b2ab7439 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -80,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -143,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error exchanging token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index fd96055a..91f763a9 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -88,7 +88,6 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } else if (error instanceof OAuthError) { res.status(400).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating bearer token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 76049c11..ecd9a7b6 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } From 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 173/194] 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 50df25b5..7e299bb0 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 3d9673da..e48970d6 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 174/194] 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 75bfe690..dce2d42e 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 7e299bb0..1a17d789 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 175/194] make protocol class not overwrite existing hooks when connecting transports --- src/shared/protocol.test.ts | 16 ++++++++++++++++ src/shared/protocol.ts | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index 5c6b72d2..b16db73f 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -65,6 +65,22 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + test("should not overwrite existing hooks when connecting transports", async () => { + const oncloseMock = jest.fn(); + const onerrorMock = jest.fn(); + const onmessageMock = jest.fn(); + transport.onclose = oncloseMock; + transport.onerror = onerrorMock; + transport.onmessage = onmessageMock; + await protocol.connect(transport); + transport.onclose(); + transport.onerror(new Error()); + transport.onmessage(""); + expect(oncloseMock).toHaveBeenCalled(); + expect(onerrorMock).toHaveBeenCalled(); + expect(onmessageMock).toHaveBeenCalled(); + }); + describe("_meta preservation with onprogress", () => { test("should preserve existing _meta when adding progressToken", async () => { await protocol.connect(transport); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index a04f26eb..942f096a 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -279,15 +279,21 @@ export abstract class Protocol< */ async connect(transport: Transport): Promise { this._transport = transport; + const _onclose = this.transport?.onclose; this._transport.onclose = () => { + _onclose?.(); this._onclose(); }; + const _onerror = this.transport?.onerror; this._transport.onerror = (error: Error) => { + _onerror?.(error); this._onerror(error); }; + const _onmessage = this._transport?.onmessage; this._transport.onmessage = (message, extra) => { + _onmessage?.(message, extra); if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { @@ -295,7 +301,9 @@ export abstract class Protocol< } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { - this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + this._onerror( + new Error(`Unknown message type: ${JSON.stringify(message)}`), + ); } }; From d89e85413303896f768cc9d44203515b129cba91 Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Fri, 20 Jun 2025 12:54:29 -0700 Subject: [PATCH 176/194] fix(client/sse): extract protected resource from eventsource 401 Previously the SSE connection would always default to the `/.well-known/oauth-protected-resource` URI, ignoring the `resource_metadata` portion of the `www-authenticate` returned in a 401. Extract the metadata from the initial 401, so RS servers with custom protected resource URIs (as in RFC9728, [section 3.1][1])) continue to work as expected. [1]: https://datatracker.ietf.org/doc/html/rfc9728#section-3.1 --- src/client/sse.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5aa99abb..2546d508 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -117,23 +117,35 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { + const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, - this._eventSourceInit ?? { - fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, { - ...init, - headers: { - ...headers, - Accept: "text/event-stream" + { + ...this._eventSourceInit, + fetch: async (url, init) => { + const headers = await this._commonHeaders() + const response = await fetchImpl(url, { + ...init, + headers: new Headers({ + ...headers, + Accept: "text/event-stream" + }) + }) + + if (response.status === 401 && response.headers.has('www-authenticate')) { + this._resourceMetadataUrl = extractResourceMetadataUrl(response); } - })), + + return response + }, }, ); this._abortController = new AbortController(); this._eventSource.onerror = (event) => { if (event.code === 401 && this._authProvider) { + this._authThenStart().then(resolve, reject); return; } From 9c3ef4f9447ef941dc797ea2597ab40ee4ce2e42 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 23 Jun 2025 14:31:50 +0100 Subject: [PATCH 177/194] 1.13.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d14ac4f4..016adf94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index bb8022fa..0439e680 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.0", + "version": "1.13.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4f24b8bc6faaea456d92f8296fbc84a560c3c8c6 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:36:54 +0800 Subject: [PATCH 178/194] Fix `/.well-known/oauth-authorization-server` dropping path --- src/client/auth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index d953e1f0..35105daa 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,7 +297,9 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + const url = new URL(wellKnownPath, authorizationServerUrl); + let response: Response; try { response = await fetch(url, { From da6ac79c1e2bcc1979f03ccaaf61094b5c9d4adf Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:41:26 +0800 Subject: [PATCH 179/194] Fix missing issuer --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 35105daa..33a9a6b9 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,6 +297,8 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { + const issuer = new URL(authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; const url = new URL(wellKnownPath, authorizationServerUrl); From 622070135242f9276b87de59b47a301fa7062cdc Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:13:13 -0400 Subject: [PATCH 180/194] Fix trailing slash --- src/client/auth.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 33a9a6b9..cba14a9c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -299,8 +299,12 @@ export async function discoverOAuthMetadata( ): Promise { const issuer = new URL(authorizationServerUrl); - const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - const url = new URL(wellKnownPath, authorizationServerUrl); + let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + if (issuer.pathname.endsWith('/')) { + // Strip trailing slash from pathname + wellKnownPath = wellKnownPath.slice(0, -1); + } + const url = new URL(wellKnownPath, issuer); let response: Response; try { From 1ff08e41d7088a63f208034a0f3bf3acfe5bf03e Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:32:08 -0400 Subject: [PATCH 181/194] Add path test --- src/client/auth.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f95cb2ca..511b351f 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,24 @@ describe("OAuth Authorization", () => { }); }); + it("returns metadata when discovery succeeds with path", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; From 03da7cfc66cf416c214bdf3236c775d7c4794c5a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 11:38:55 +0100 Subject: [PATCH 182/194] fallback --- src/client/auth.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 88 ++++++++++++++++++++++------- 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 511b351f..b689d188 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -225,6 +225,126 @@ describe("OAuth Authorization", () => { }); }); + it("falls back to root discovery when path-aware discovery returns 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(firstOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("returns undefined when both path-aware and root discovery return 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("does not fallback when the original URL is already at root path", async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("does not fallback when the original URL has no path", async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("falls back when path-aware discovery encounters CORS error", async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; diff --git a/src/client/auth.ts b/src/client/auth.ts index cba14a9c..e0e93fc0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -293,36 +293,82 @@ export async function discoverOAuthProtectedResourceMetadata( * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. */ +/** + * Helper function to handle fetch with CORS retry logic + */ +async function fetchWithCorsRetry( + url: URL, + headers: Record, +): Promise { + try { + return await fetch(url, { headers }); + } catch (error) { + // CORS errors come back as TypeError, retry without headers + if (error instanceof TypeError) { + return await fetch(url); + } + throw error; + } +} + +/** + * Constructs the well-known path for OAuth metadata discovery + */ +function buildWellKnownPath(pathname: string): string { + let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; + if (pathname.endsWith('/')) { + // Strip trailing slash from pathname to avoid double slashes + wellKnownPath = wellKnownPath.slice(0, -1); + } + return wellKnownPath; +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, +): Promise { + const headers = { + "MCP-Protocol-Version": protocolVersion + }; + return await fetchWithCorsRetry(url, headers); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback(response: Response, pathname: string): boolean { + return response.status === 404 && pathname !== '/'; +} + export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { const issuer = new URL(authorizationServerUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - if (issuer.pathname.endsWith('/')) { - // Strip trailing slash from pathname - wellKnownPath = wellKnownPath.slice(0, -1); - } - const url = new URL(wellKnownPath, issuer); + // Try path-aware discovery first (RFC 8414 compliant) + const wellKnownPath = buildWellKnownPath(issuer.pathname); + const pathAwareUrl = new URL(wellKnownPath, issuer); + let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION + // If path-aware discovery fails with 404, try fallback to root discovery + if (shouldAttemptFallback(response, issuer.pathname)) { + try { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + + if (response.status === 404) { + return undefined; } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; + } catch { + // If fallback fails, return undefined + return undefined; } - } - - if (response.status === 404) { + } else if (response.status === 404) { return undefined; } From 15a2277c8994a403dfedaf52d27eae73fdc359af Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 24 Jun 2025 14:47:54 +0100 Subject: [PATCH 183/194] refactor resource selection to not include resource if PRM is not present --- src/client/auth.test.ts | 231 ++++++++++++++++++++++++++++++++++++++-- src/client/auth.ts | 27 +++-- 2 files changed, 241 insertions(+), 17 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b689d188..8e77c0a5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -954,10 +954,19 @@ describe("OAuth Authorization", () => { }); it("passes resource parameter through authorization flow", async () => { - // Mock successful metadata discovery + // Mock successful metadata discovery - need to include protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1002,11 +1011,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token exchange when authorization code is provided", async () => { - // Mock successful metadata discovery and token exchange + // Mock successful metadata discovery and token exchange - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1062,11 +1080,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token refresh", async () => { - // Mock successful metadata discovery and token refresh + // Mock successful metadata discovery and token refresh - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1244,5 +1271,197 @@ describe("OAuth Authorization", () => { // Should use the PRM's resource value, not the full requested URL expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); }); + + it("excludes resource parameter when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("excludes resource parameter in token exchange when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + authorizationCode: "auth-code-123", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("excludes resource parameter in token refresh when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index e0e93fc0..37690574 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -198,19 +198,24 @@ export async function auth( } export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { - let resource = resourceUrlFromServerUrl(serverUrl); + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it if (provider.validateResourceURL) { - return await provider.validateResourceURL(resource, resourceMetadata?.resource); - } else if (resourceMetadata) { - if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { - // If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request. - resource = new URL(resourceMetadata.resource); - } else { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); - } + return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); } - return resource; + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); + } + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); } /** @@ -360,7 +365,7 @@ export async function discoverOAuthMetadata( try { const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion); - + if (response.status === 404) { return undefined; } From c20a47a79f38f617ffd9ef0df1106651891d9ea7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 17:09:29 +0100 Subject: [PATCH 184/194] small fixes --- src/server/mcp.test.ts | 108 ++++++++++++++++++++--------------------- src/server/mcp.ts | 2 +- 2 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 242f0529..e09ab511 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1203,72 +1203,68 @@ describe("tool()", () => { }), ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); - - /*** + /*** * Test: Tool with Output Schema Must Provide Structured Content */ - test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - "test", - { - description: "Test tool with output schema but missing structured content", - inputSchema: { - input: z.string(), - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - }, + test("should skip outputSchema validation when isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ - { - type: "text", - text: `Processed: ${input}`, - }, - ], - isError: true, - }) - ); - - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool and expect it to not throw an error - await expect( - client.callTool({ - name: "test", - arguments: { - input: "hello", - }, - }), - ).resolves.toStrictEqual({ + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ content: [ { type: "text", - text: `Processed: hello`, + text: `Processed: ${input}`, }, ], isError: true, - }); + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, }); + }); /*** * Test: Schema Validation Failure for Invalid Structured Content diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9440708d..67da78ff 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema && (result.isError !== true)) { + if (tool.outputSchema && !result.isError) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From 166da76b0070a431c9f254a59e799a8c927f84fb Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Tue, 24 Jun 2025 19:39:37 +0300 Subject: [PATCH 185/194] extra parameter - remain optional for backwards compatibility --- package-lock.json | 1 + src/server/sse.ts | 4 ++-- src/server/streamableHttp.ts | 2 +- src/server/types/types.ts | 2 +- src/shared/protocol.ts | 6 +++--- src/shared/transport.ts | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9dd8236b..016adf94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,6 @@ { "name": "@modelcontextprotocol/sdk", + "version": "1.13.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/server/sse.ts b/src/server/sse.ts index 06c0bc8d..a54e5788 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -20,7 +20,7 @@ export class SSEServerTransport implements Transport { private _sessionId: string; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra: { authInfo?: AuthInfo, requestInfo: RequestInfo }) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -119,7 +119,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, extra: MessageExtraInfo): Promise { + async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b5f8aca7..807743eb 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -114,7 +114,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra: MessageExtraInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; diff --git a/src/server/types/types.ts b/src/server/types/types.ts index 1114e50b..3892af6c 100644 --- a/src/server/types/types.ts +++ b/src/server/types/types.ts @@ -22,7 +22,7 @@ export interface MessageExtraInfo { /** * The request information. */ - requestInfo: RequestInfo; + requestInfo?: RequestInfo; /** * The authentication information. diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ae539c17..33afd70e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -131,7 +131,7 @@ export type RequestHandlerExtra void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; /** * The session ID generated for this connection. From 05d98bb3a4b8feab427bf71f11c77b2266132a7b Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Wed, 25 Jun 2025 03:20:26 -0700 Subject: [PATCH 186/194] feat(shared/auth): support software_statement in OAuthClientMetadata (#696) Per [Section 3.1.1][ref], `software_statement` is an OPTIONAL member of the client creation request, which may contain a JWT encoding claims about client software. [ref]: https://datatracker.ietf.org/doc/html/rfc7591#section-3.1.1 --- src/shared/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 65b800e7..b906de3d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -98,6 +98,7 @@ export const OAuthClientMetadataSchema = z.object({ jwks: z.any().optional(), software_id: z.string().optional(), software_version: z.string().optional(), + software_statement: z.string().optional(), }).strip(); /** From 606c278668c4328b2592da73f59d1b98b2ccf062 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 25 Jun 2025 16:24:30 +0300 Subject: [PATCH 187/194] clean up tests - remove mockRequestInfo --- src/client/index.test.ts | 13 +++---------- src/server/index.test.ts | 16 ++++------------ src/server/mcp.test.ts | 13 ++----------- src/server/sse.test.ts | 2 +- src/server/sse.ts | 3 +-- src/server/streamableHttp.ts | 3 +-- src/server/types/types.ts | 31 ------------------------------- src/shared/protocol.ts | 3 ++- src/shared/transport.ts | 3 +-- src/types.ts | 31 +++++++++++++++++++++++++++++++ 10 files changed, 46 insertions(+), 72 deletions(-) delete mode 100644 src/server/types/types.ts diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 02d6781c..abd0c34e 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -21,14 +21,7 @@ import { import { Transport } from "../shared/transport.js"; import { Server } from "../server/index.js"; import { InMemoryTransport } from "../inMemory.js"; -import { RequestInfo } from "../server/types/types.js"; -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'accept': 'application/json', - }, -}; /*** * Test: Initialize with Matching Protocol Version */ @@ -50,7 +43,7 @@ test("should initialize with matching protocol version", async () => { }, instructions: "test instructions", }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), @@ -108,7 +101,7 @@ test("should initialize with supported older protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), @@ -158,7 +151,7 @@ test("should reject unsupported protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); } return Promise.resolve(); }), diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 137b8934..d91b90a9 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -15,19 +15,11 @@ import { ListResourcesRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, - ErrorCode, + ErrorCode } from "../types.js"; import { Transport } from "../shared/transport.js"; import { InMemoryTransport } from "../inMemory.js"; import { Client } from "../client/index.js"; -import { RequestInfo } from "./types/types.js"; - -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', - }, -}; test("should accept latest protocol version", async () => { let sendPromiseResolve: (value: unknown) => void; @@ -86,7 +78,7 @@ test("should accept latest protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -147,7 +139,7 @@ test("should accept supported older protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); @@ -207,7 +199,7 @@ test("should handle unsupported protocol version", async () => { version: "1.0", }, }, - }, { requestInfo: mockRequestInfo }); + }); await expect(sendPromise).resolves.toBeUndefined(); }); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index d208d51e..0764ffe8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,21 +14,13 @@ import { LoggingMessageNotificationSchema, Notification, TextContent, - ElicitRequestSchema, + ElicitRequestSchema } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; -import { RequestInfo } from "./types/types.js"; import { getDisplayName } from "../shared/metadataUtils.js"; -const mockRequestInfo: RequestInfo = { - headers: { - 'content-type': 'application/json', - 'accept': 'application/json', - }, -}; - describe("McpServer", () => { /*** * Test: Basic Server Instance @@ -222,8 +214,7 @@ describe("ResourceTemplate", () => { signal: abortController.signal, requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, - sendNotification: () => { throw new Error("Not implemented") }, - requestInfo: mockRequestInfo + sendNotification: () => { throw new Error("Not implemented") } }); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 7edef6af..703cc514 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -232,7 +232,7 @@ describe('SSEServerTransport', () => { ); }); - /*** + /** * Test: Tool With Request Info */ it("should pass request info to tool callback", async () => { diff --git a/src/server/sse.ts b/src/server/sse.ts index a54e5788..de4dd60a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,11 +1,10 @@ import { randomUUID } from "node:crypto"; import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { AuthInfo } from "./auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "./types/types.js"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 807743eb..677da45e 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,10 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; +import { MessageExtraInfo, RequestInfo, isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; import { AuthInfo } from "./auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "./types/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; diff --git a/src/server/types/types.ts b/src/server/types/types.ts deleted file mode 100644 index 3892af6c..00000000 --- a/src/server/types/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AuthInfo } from "../auth/types.js"; - -/** - * Headers that are compatible with both Node.js and the browser. - */ -export type IsomorphicHeaders = Record; - -/** - * Information about the incoming request. - */ -export interface RequestInfo { - /** - * The headers of the request. - */ - headers: IsomorphicHeaders; -} - -/** - * Extra information about a message. - */ -export interface MessageExtraInfo { - /** - * The request information. - */ - requestInfo?: RequestInfo; - - /** - * The authentication information. - */ - authInfo?: AuthInfo; -} \ No newline at end of file diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 33afd70e..35839a4f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,10 +22,11 @@ import { Result, ServerCapabilities, RequestMeta, + MessageExtraInfo, + RequestInfo, } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; -import { MessageExtraInfo, RequestInfo } from "../server/types/types.js"; /** * Callback for progress notifications. diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 69fce10e..96b291fa 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,4 @@ -import { MessageExtraInfo } from "../server/types/types.js"; -import { JSONRPCMessage, RequestId } from "../types.js"; +import { JSONRPCMessage, MessageExtraInfo, RequestId } from "../types.js"; /** * Options for sending a JSON-RPC message. diff --git a/src/types.ts b/src/types.ts index 3606a6be..f66d2c4b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { z, ZodTypeAny } from "zod"; +import { AuthInfo } from "./server/auth/types.js"; export const LATEST_PROTOCOL_VERSION = "2025-06-18"; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; @@ -1463,6 +1464,36 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo?: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; +} + /* JSON-RPC types */ export type ProgressToken = Infer; export type Cursor = Infer; From 5f96ae4d8954daf656a9b69a73b249f748cfc75d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 25 Jun 2025 11:34:44 -0400 Subject: [PATCH 188/194] update PR#633 to address the comment about improving the example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index abf57afb..de297dc9 100644 --- a/README.md +++ b/README.md @@ -508,7 +508,7 @@ app.listen(3000); > ```ts > app.use( > cors({ -> origin: '*', +> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], > exposedHeaders: ['mcp-session-id'], > allowedHeaders: ['Content-Type', 'mcp-session-id'], > }) From 362acfc1ce435f97987cf786838523645073f8fa Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 26 Jun 2025 09:51:26 -0700 Subject: [PATCH 189/194] Update package-lock.json --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b5d6e36..d6e1b9f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,12 @@ { "name": "@modelcontextprotocol/sdk", -<<<<<<< cb/sse-tests -- Incoming Change - "version": "1.10.2", -======= "version": "1.13.1", ->>>>>>> main -- Current Change "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", -<<<<<<< cb/sse-tests -- Incoming Change - "version": "1.10.2", -======= "version": "1.13.1", ->>>>>>> main -- Current Change "license": "MIT", "dependencies": { "ajv": "^6.12.6", From f76652bb100ee59470359ea440502cb1c02e7c56 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 26 Jun 2025 10:22:27 -0700 Subject: [PATCH 190/194] Update src/server/sse.test.ts --- src/server/sse.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 4aceb734..32c894f0 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -418,7 +418,12 @@ describe('SSEServerTransport', () => { }, { authInfo: { token: 'test-token', - } + }, + requestInfo: { + headers: { + 'content-type': 'application/json', + }, + }, }); }); }); From 9d678ce5912c86c1d12867b9a726365d9295c1a9 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 26 Jun 2025 18:52:24 +0100 Subject: [PATCH 191/194] 1.13.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6e1b9f5..9f1d43a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 0439e680..8feb10af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.13.1", + "version": "1.13.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 8bc7374f5ee69d4af79d6703fee244231a4b3d95 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Thu, 26 Jun 2025 16:12:26 -0700 Subject: [PATCH 192/194] Fix merge conflict --- src/server/sse.test.ts | 72 ++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index a0a06cb9..a7f18096 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -469,8 +469,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -489,8 +491,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'evil.com', - 'content-type': 'application/json', + headers: { + host: 'evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -509,7 +513,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json', + headers: { + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -530,8 +536,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -550,8 +558,10 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - origin: 'http://evil.com', - 'content-type': 'application/json', + headers: { + origin: 'http://evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -569,7 +579,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json', + headers: { + 'content-type': 'application/json', + } }); const mockHandleRes = createMockResponse(); @@ -585,7 +597,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'application/json; charset=utf-8', + headers: { + 'content-type': 'application/json; charset=utf-8', + } }); const mockHandleRes = createMockResponse(); @@ -601,7 +615,9 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - 'content-type': 'text/plain', + headers: { + 'content-type': 'text/plain', + } }); const mockHandleRes = createMockResponse(); @@ -623,9 +639,11 @@ describe('SSEServerTransport', () => { await transport.start(); const mockReq = createMockRequest({ - host: 'evil.com', - origin: 'http://evil.com', - 'content-type': 'text/plain', + headers: { + host: 'evil.com', + origin: 'http://evil.com', + 'content-type': 'text/plain', + } }); const mockHandleRes = createMockResponse(); @@ -650,9 +668,11 @@ describe('SSEServerTransport', () => { // Valid host, invalid origin const mockReq1 = createMockRequest({ - host: 'localhost:3000', - origin: 'http://evil.com', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + origin: 'http://evil.com', + 'content-type': 'application/json', + } }); const mockHandleRes1 = createMockResponse(); @@ -663,9 +683,11 @@ describe('SSEServerTransport', () => { // Invalid host, valid origin const mockReq2 = createMockRequest({ - host: 'evil.com', - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'evil.com', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes2 = createMockResponse(); @@ -676,9 +698,11 @@ describe('SSEServerTransport', () => { // Both valid const mockReq3 = createMockRequest({ - host: 'localhost:3000', - origin: 'http://localhost:3000', - 'content-type': 'application/json', + headers: { + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'content-type': 'application/json', + } }); const mockHandleRes3 = createMockResponse(); From 53ad0a01d56a978da8ca37cff99e484113cdf337 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 27 Jun 2025 14:14:21 +0100 Subject: [PATCH 193/194] fix lint --- src/server/streamableHttp.test.ts | 54 +++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index f93e7e96..502435ea 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -8,17 +8,17 @@ import { z } from "zod"; import { AuthInfo } from "./auth/types.js"; async function getFreePort() { - return new Promise( res => { - const srv = netCreateServer(); - srv.listen(0, () => { - const address = srv.address()! - if (typeof address === "string") { - throw new Error("Unexpected address type: " + typeof address); - } - const port = (address as AddressInfo).port; - srv.close((err) => res(port)) - }); - }) + return new Promise(res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()! + if (typeof address === "string") { + throw new Error("Unexpected address type: " + typeof address); + } + const port = (address as AddressInfo).port; + srv.close((_err) => res(port)) + }); + }) } /** @@ -377,7 +377,7 @@ describe("StreamableHTTPServerTransport", () => { return { content: [{ type: "text", text: `Hello, ${name}!` }, { type: "text", text: `${JSON.stringify(requestInfo)}` }] }; } ); - + const toolCallMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "tools/call", @@ -828,7 +828,7 @@ describe("StreamableHTTPServerTransport", () => { // Send request with matching protocol version const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - + expect(response.status).toBe(200); }); @@ -846,7 +846,7 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + expect(response.status).toBe(200); }); @@ -864,7 +864,7 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -872,13 +872,13 @@ describe("StreamableHTTPServerTransport", () => { it("should accept when protocol version differs from negotiated version", async () => { sessionId = await initializeServer(); - + // Spy on console.warn to verify warning is logged const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); // Send request with different but supported protocol version const response = await fetch(baseUrl, { - method: "POST", + method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", @@ -887,10 +887,10 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(TEST_MESSAGES.toolsList), }); - + // Request should still succeed expect(response.status).toBe(200); - + warnSpy.mockRestore(); }); @@ -906,7 +906,7 @@ describe("StreamableHTTPServerTransport", () => { "mcp-protocol-version": "invalid-version", }, }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -923,7 +923,7 @@ describe("StreamableHTTPServerTransport", () => { "mcp-protocol-version": "invalid-version", }, }); - + expect(response.status).toBe(400); const errorData = await response.json(); expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); @@ -965,12 +965,12 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { method: "tools/call", params: { name: "profile", - arguments: {active: true}, + arguments: { active: true }, }, id: "call-1", }; - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, {'authorization': 'Bearer test-token'}); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { 'authorization': 'Bearer test-token' }); expect(response.status).toBe(200); const text = await readSSEEvent(response); @@ -992,7 +992,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { id: "call-1", }); }); - + it("should calls tool without authInfo when it is optional", async () => { sessionId = await initializeServer(); @@ -1001,7 +1001,7 @@ describe("StreamableHTTPServerTransport with AuthInfo", () => { method: "tools/call", params: { name: "profile", - arguments: {active: false}, + arguments: { active: false }, }, id: "call-1", }; @@ -1485,7 +1485,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open first SSE stream const stream1 = await fetch(baseUrl, { method: "GET", - headers: { + headers: { Accept: "text/event-stream", "mcp-protocol-version": "2025-03-26" }, @@ -1495,7 +1495,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open second SSE stream - should still be rejected, stateless mode still only allows one const stream2 = await fetch(baseUrl, { method: "GET", - headers: { + headers: { Accept: "text/event-stream", "mcp-protocol-version": "2025-03-26" }, 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 194/194] 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 dce2d42e..de3b363e 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: {