From 940054e71c190abec45768b06f23e61e0b79c831 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 11 Apr 2025 14:23:57 +0200 Subject: [PATCH 01/51] 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 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 02/51] [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 03/51] [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 30b5544e8b88fe9a46243259699645f83cab0553 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 2 May 2025 08:40:58 +0000 Subject: [PATCH 04/51] 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 ec7a794f280ac4f4cbf51d16810cd1777f480fed Mon Sep 17 00:00:00 2001 From: Peter Vandenabeele Date: Mon, 5 May 2025 21:53:47 +0200 Subject: [PATCH 05/51] 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 c2397b877c69a3e0427f7e0e3817165113be19f1 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Wed, 7 May 2025 03:15:14 +0800 Subject: [PATCH 06/51] 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 b6617dc44c409c5f8c246441059d652ff61e2efa Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 9 May 2025 15:23:50 +1000 Subject: [PATCH 07/51] 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 08/51] 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 09/51] 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 10/51] 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 11/51] 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 12/51] 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 13/51] 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 14/51] 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 66dec56467b8f562b2c8cec8c2b41f97dd819777 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 15 May 2025 01:45:18 +0800 Subject: [PATCH 15/51] 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 16/51] 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 17/51] 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 18/51] 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 19/51] 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 20/51] 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 21/51] 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 22/51] 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 23/51] - 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 24/51] 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 25/51] 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 26/51] 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 27/51] 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 28/51] 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 29/51] 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 30/51] 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 31/51] 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 c964c5cf91828046efed1e392a09772e54d974fe Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 15 May 2025 10:40:34 -0400 Subject: [PATCH 33/51] 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 34/51] 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 35/51] 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 36/51] 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 37/51] 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 38/51] 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 39/51] 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 40/51] 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 41/51] 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 42/51] 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 43/51] 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 44/51] 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 45/51] 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 46/51] 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 47/51] 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 48/51] 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 cbe9279206a0fcdfa3bc6361f552ebba6cf24c66 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 22 May 2025 10:38:43 +0100 Subject: [PATCH 49/51] 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 50/51] 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 51/51] 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)",