diff --git a/README.md b/README.md index bcdf0d841..170acfc71 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ The Model Context Protocol allows applications to provide context for LLMs in a ## Installation ```bash -npm install @modelcontextprotocol/sdk zod +npm install @modelcontextprotocol/sdk +# plus your preferred Standard Schema library, e.g. +npm install zod # if you follow the Zod examples below ``` -This SDK has a **required peer dependency** on `zod` for schema validation. The SDK internally imports from `zod/v4`, but maintains backwards compatibility with projects using Zod v3.25 or later. You can use either API in your code by importing from `zod/v3` or `zod/v4`: +The SDK speaks [Standard Schema](https://standardschema.dev/) for validation. Bring any Standard Schema-compatible library (Zod, Valibot, ArkType, etc). Examples below use Zod, and the SDK still supports both Zod v3.25+ and v4. ## Quick Start @@ -161,6 +163,8 @@ const server = new McpServer({ [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) let LLMs take actions through your server. Tools can perform computation, fetch data and have side effects. Tools should be designed to be model-controlled - i.e. AI models will decide which tools to call, and the arguments. +Tool schemas accept any Standard Schema validator. The SDK converts Standard Schemas to JSON Schema for clients (Zod, Valibot, ArkType, etc). + ```typescript // Simple tool with parameters server.registerTool( diff --git a/package-lock.json b/package-lock.json index d6791ef06..2fb737a17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.23.0-beta.0", "license": "MIT", "dependencies": { + "@standard-community/standard-json": "^0.3.5", + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -35,6 +38,7 @@ "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", + "@valibot/to-json-schema": "^1.3.0", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", @@ -42,6 +46,7 @@ "tsx": "^4.16.5", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", + "valibot": "^1.1.0", "vitest": "^4.0.8", "ws": "^8.18.0" }, @@ -49,15 +54,11 @@ "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@cfworker/json-schema": "^4.1.1" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true - }, - "zod": { - "optional": false } } }, @@ -1057,11 +1058,62 @@ "win32" ] }, + "node_modules/@standard-community/standard-json": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@standard-community/standard-json/-/standard-json-0.3.5.tgz", + "integrity": "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==", + "license": "MIT", + "peerDependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/json-schema": "^7.0.15", + "@valibot/to-json-schema": "^1.3.0", + "arktype": "^2.1.20", + "effect": "^3.16.8", + "quansync": "^0.2.11", + "sury": "^10.0.0", + "typebox": "^1.0.17", + "valibot": "^1.1.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependenciesMeta": { + "@valibot/to-json-schema": { + "optional": true + }, + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "sury": { + "optional": true + }, + "typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + }, + "zod-to-json-schema": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@types/body-parser": { @@ -1184,7 +1236,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "peer": true }, "node_modules/@types/methods": { "version": "1.1.4", @@ -1315,6 +1367,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/types": "8.11.0", @@ -1624,6 +1677,17 @@ "win32" ] }, + "node_modules/@valibot/to-json-schema": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.3.0.tgz", + "integrity": "sha512-82Vv6x7sOYhv5YmTRgSppSqj1nn2pMCk5BqCMGWYp0V/fq+qirrbGncqZAtZ09/lrO40ne/7z8ejwE728aVreg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "valibot": "^1.1.0" + } + }, "node_modules/@vitest/expect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", @@ -1726,6 +1790,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2280,6 +2345,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -3620,6 +3686,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT", + "peer": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4138,6 +4221,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4193,6 +4277,7 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4238,6 +4323,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4302,6 +4388,22 @@ "node": ">= 0.4.0" } }, + "node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4439,6 +4541,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4452,6 +4555,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4604,6 +4708,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4613,6 +4718,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/package.json b/package.json index 560c4bcb2..24bea2fdf 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,9 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "@standard-community/standard-json": "^0.3.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -93,15 +96,11 @@ "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@cfworker/json-schema": "^4.1.1" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true - }, - "zod": { - "optional": false } }, "devDependencies": { @@ -116,6 +115,7 @@ "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", + "@valibot/to-json-schema": "^1.3.0", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", @@ -123,6 +123,7 @@ "tsx": "^4.16.5", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", + "valibot": "^1.1.0", "vitest": "^4.0.8", "ws": "^8.18.0" }, diff --git a/src/server/mcp.ts b/src/server/mcp.ts index b9b6d5596..ce47329d9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -110,43 +110,42 @@ export class McpServer { } }); - this.server.setRequestHandler( - ListToolsRequestSchema, - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) + this.server.setRequestHandler(ListToolsRequestSchema, async (): Promise => { + const tools = await Promise.all( + Object.entries(this._registeredTools) .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { + .map(async ([name, tool]): Promise => { + const inputObj = normalizeObjectSchema(tool.inputSchema); const toolDefinition: Tool = { name, title: tool.title, description: tool.description, - inputSchema: (() => { - const obj = normalizeObjectSchema(tool.inputSchema); - return obj - ? (toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: 'input' - }) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA; - })(), + inputSchema: inputObj + ? ((await toJsonSchemaCompat(inputObj, { + strictUnions: true, + pipeStrategy: 'input' + })) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, _meta: tool._meta }; if (tool.outputSchema) { - const obj = normalizeObjectSchema(tool.outputSchema); - if (obj) { - toolDefinition.outputSchema = toJsonSchemaCompat(obj, { + const outputObj = normalizeObjectSchema(tool.outputSchema); + if (outputObj) { + toolDefinition.outputSchema = (await toJsonSchemaCompat(outputObj, { strictUnions: true, pipeStrategy: 'output' - }) as Tool['outputSchema']; + })) as Tool['outputSchema']; } } return toolDefinition; }) - }) - ); + ); + + return { tools }; + }); this.server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { const tool = this._registeredTools[request.params.name]; @@ -163,7 +162,10 @@ export class McpServer { } if (tool.inputSchema) { - const cb = tool.callback as ToolCallback; + const cb = tool.callback as ( + args: unknown, + extra: RequestHandlerExtra + ) => Promise | CallToolResult; // Try to normalize to object schema first (for raw shapes and object schemas) // If that fails, use the schema directly (for union/intersection/etc) const inputObj = normalizeObjectSchema(tool.inputSchema); @@ -703,11 +705,14 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); + const normalizedInputSchema = getZodSchemaObject(inputSchema); + const normalizedOutputSchema = getZodSchemaObject(outputSchema); + const registeredTool: RegisteredTool = { title, description, - inputSchema: getZodSchemaObject(inputSchema), - outputSchema: getZodSchemaObject(outputSchema), + inputSchema: normalizedInputSchema, + outputSchema: normalizedOutputSchema, annotations, _meta, callback, @@ -726,6 +731,7 @@ export class McpServer { if (typeof updates.title !== 'undefined') registeredTool.title = updates.title; if (typeof updates.description !== 'undefined') registeredTool.description = updates.description; if (typeof updates.paramsSchema !== 'undefined') registeredTool.inputSchema = objectFromShape(updates.paramsSchema); + if (typeof updates.outputSchema !== 'undefined') registeredTool.outputSchema = objectFromShape(updates.outputSchema); if (typeof updates.callback !== 'undefined') registeredTool.callback = updates.callback; if (typeof updates.annotations !== 'undefined') registeredTool.annotations = updates.annotations; if (typeof updates._meta !== 'undefined') registeredTool._meta = updates._meta; diff --git a/src/server/valibot-compat.test.ts b/src/server/valibot-compat.test.ts new file mode 100644 index 000000000..1f7ae3b85 --- /dev/null +++ b/src/server/valibot-compat.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { object, string } from 'valibot'; + +import { safeParseAsync, type AnyObjectSchema } from './zod-compat.js'; +import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; + +describe('Standard Schema (Valibot) compatibility', () => { + const valibotSchema = object({ name: string() }); + + test('safeParseAsync validates Valibot schema', async () => { + const ok = await safeParseAsync(valibotSchema, { name: 'alice' }); + expect(ok.success).toBe(true); + if (ok.success) { + expect(ok.data).toEqual({ name: 'alice' }); + } + + const bad = await safeParseAsync(valibotSchema, { name: 123 }); + expect(bad.success).toBe(false); + }); + + test('toJsonSchemaCompat converts Valibot schema to JSON Schema', async () => { + const jsonSchema = await toJsonSchemaCompat(valibotSchema as unknown as AnyObjectSchema, { + pipeStrategy: 'input' + }); + + expect(jsonSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }); + }); +}); diff --git a/src/server/zod-compat.test.ts b/src/server/zod-compat.test.ts new file mode 100644 index 000000000..806fc2de6 --- /dev/null +++ b/src/server/zod-compat.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { SchemaError } from '@standard-schema/utils'; + +import { objectFromShape, safeParse, safeParseAsync } from './zod-compat.js'; + +const standardString: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'custom', + types: { input: '' as string, output: '' as string }, + validate(value) { + return typeof value === 'string' ? { value } : { issues: [{ message: 'Expected string', path: [] }] }; + } + } +}; + +describe('zod-compat with Standard Schema', () => { + test('safeParse works with Standard Schema input', () => { + const success = safeParse(standardString, 'hello'); + expect(success.success).toBe(true); + if (success.success) { + expect(success.data).toBe('hello'); + } + + const failure = safeParse(standardString, 42); + expect(failure.success).toBe(false); + if (!failure.success) { + expect(failure.error).toBeInstanceOf(SchemaError); + } + }); + + test('objectFromShape validates Standard Schema members', async () => { + const shapeSchema = objectFromShape({ name: standardString }); + + const ok = await safeParseAsync(shapeSchema, { name: 'world' }); + expect(ok.success).toBe(true); + if (ok.success) { + expect(ok.data).toEqual({ name: 'world' }); + } + + const bad = await safeParseAsync(shapeSchema, { name: 123 }); + expect(bad.success).toBe(false); + if (!bad.success) { + expect(bad.error).toBeInstanceOf(SchemaError); + const issues = (bad.error as SchemaError).issues; + expect(issues[0]?.path?.[0]).toBe('name'); + } + }); +}); diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index 956aca821..538fd4d93 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -1,16 +1,21 @@ // zod-compat.ts // ---------------------------------------------------- -// Unified types + helpers to accept Zod v3 and v4 (Mini) +// Unified helpers that now prefer the Standard Schema interface while +// keeping backwards-compatible support for Zod v3/v4 schemas. // ---------------------------------------------------- import type * as z3 from 'zod/v3'; import type * as z4 from 'zod/v4/core'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { SchemaError } from '@standard-schema/utils'; import * as z3rt from 'zod/v3'; import * as z4mini from 'zod/v4-mini'; // --- Unified schema types --- -export type AnySchema = z3.ZodTypeAny | z4.$ZodType; +type StandardLikeSchema = { readonly ['~standard']?: StandardSchemaV1.Props }; + +export type AnySchema = (z3.ZodTypeAny | z4.$ZodType | StandardSchemaV1) & StandardLikeSchema; export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject | AnySchema; export type ZodRawShapeCompat = Record; @@ -42,9 +47,21 @@ export interface ZodV4Internal { } // --- Type inference helpers --- -export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends z4.$ZodType ? z4.output : never; +export type SchemaOutput = S extends { ['~standard']: { types: { output: infer O } } } + ? O + : S extends z3.ZodTypeAny + ? z3.infer + : S extends z4.$ZodType + ? z4.output + : unknown; -export type SchemaInput = S extends z3.ZodTypeAny ? z3.input : S extends z4.$ZodType ? z4.input : never; +export type SchemaInput = S extends { ['~standard']: { types: { input: infer I } } } + ? I + : S extends z3.ZodTypeAny + ? z3.input + : S extends z4.$ZodType + ? z4.input + : unknown; /** * Infers the output type from a ZodRawShapeCompat (raw shape object). @@ -54,25 +71,130 @@ export type ShapeOutput = { [K in keyof Shape]: SchemaOutput; }; +export type ShapeInput = { + [K in keyof Shape]: SchemaInput; +}; + // --- Runtime detection --- +function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { + return !!schema && typeof schema === 'object' && '~standard' in schema; +} + export function isZ4Schema(s: AnySchema): s is z4.$ZodType { // Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3 const schema = s as unknown as ZodV4Internal; return !!schema._zod; } +function isZodSchema(schema: AnySchema): schema is z3.ZodTypeAny | z4.$ZodType { + if (isStandardSchema(schema) && schema['~standard']?.vendor === 'zod') { + return true; + } + const internal = schema as unknown as ZodV3Internal | ZodV4Internal; + if ('_def' in (internal as object)) { + return !!(internal as ZodV3Internal)._def; + } + if ('_zod' in (internal as object)) { + return !!(internal as ZodV4Internal)._zod; + } + return false; +} + +function toIssues(error: unknown): StandardSchemaV1.Issue[] | undefined { + if (error instanceof SchemaError) { + return Array.from(error.issues); + } + if (Array.isArray(error)) { + const typed = error as unknown[]; + if (typed.every(issue => issue && typeof issue === 'object' && 'message' in (issue as { message?: unknown }))) { + return typed as StandardSchemaV1.Issue[]; + } + } + if (error && typeof error === 'object' && 'issues' in error && Array.isArray((error as { issues: unknown }).issues)) { + const issues = (error as { issues: unknown[] }).issues; + if (issues.every(issue => issue && typeof issue === 'object' && 'message' in (issue as { message?: unknown }))) { + return issues as StandardSchemaV1.Issue[]; + } + } + return undefined; +} + // --- Schema construction --- -export function objectFromShape(shape: ZodRawShapeCompat): AnyObjectSchema { +function createStandardObjectFromShape>(shape: Shape): AnyObjectSchema { + const standardSchema: StandardSchemaV1, ShapeOutput> & { _shape: Shape } = { + _shape: shape, + '~standard': { + version: 1, + vendor: '@modelcontextprotocol/sdk', + types: { + input: {} as ShapeInput, + output: {} as ShapeOutput + }, + async validate(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { + issues: [ + { + message: 'Expected object', + path: [] + } + ] + }; + } + + const issues: StandardSchemaV1.Issue[] = []; + const output: Record = {}; + + for (const [key, propSchema] of Object.entries(shape)) { + const propValue = (value as Record)[key]; + const result = await safeParseAsync(propSchema, propValue); + + if (!result.success) { + const propIssues = toIssues(result.error) ?? []; + for (const issue of propIssues as StandardSchemaV1.Issue[]) { + const path = issue.path ? [key, ...issue.path] : [key]; + issues.push({ ...issue, path }); + } + if (propIssues.length === 0) { + issues.push({ + message: getParseErrorMessage(result.error), + path: [key] + }); + } + } else { + output[key] = result.data; + } + } + + if (issues.length > 0) { + return { issues }; + } + + return { value: output as ShapeOutput }; + } + } + }; + + return standardSchema as unknown as AnyObjectSchema; +} + +export function objectFromShape(shape: Record): AnyObjectSchema { const values = Object.values(shape); if (values.length === 0) return z4mini.object({}); // default to v4 Mini const allV4 = values.every(isZ4Schema); - const allV3 = values.every(s => !isZ4Schema(s)); + const allV3 = values.every(s => isZodSchema(s) && !isZ4Schema(s)); + const hasZod = values.some(isZodSchema); + const allStandardNonZod = values.every(s => isStandardSchema(s) && !isZodSchema(s)); if (allV4) return z4mini.object(shape as Record); if (allV3) return z3rt.object(shape as Record); + if (hasZod) { + throw new Error('Mixed Zod versions detected in object shape.'); + } + if (allStandardNonZod) return createStandardObjectFromShape(shape); - throw new Error('Mixed Zod versions detected in object shape.'); + throw new Error('Mixed schema types detected in object shape. Please use a single schema library or provide a Standard Schema object.'); } // --- Unified parsing --- @@ -80,34 +202,66 @@ export function safeParse( schema: S, data: unknown ): { success: true; data: SchemaOutput } | { success: false; error: unknown } { + if (isStandardSchema(schema)) { + const result = schema['~standard'].validate(data); + if (result instanceof Promise) { + return { success: false, error: new Error('Schema validation is async; use safeParseAsync instead') }; + } + if (result.issues) { + return { success: false, error: new SchemaError(result.issues) }; + } + return { success: true, data: result.value as SchemaOutput }; + } + if (isZ4Schema(schema)) { - // Mini exposes top-level safeParse const result = z4mini.safeParse(schema, data); return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; } - const v3Schema = schema as z3.ZodTypeAny; - const result = v3Schema.safeParse(data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + + if (isZodSchema(schema)) { + const v3Schema = schema as z3.ZodTypeAny; + const result = v3Schema.safeParse(data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + } + + return { success: false, error: new Error('Unsupported schema type') }; } export async function safeParseAsync( schema: S, data: unknown ): Promise<{ success: true; data: SchemaOutput } | { success: false; error: unknown }> { + if (isStandardSchema(schema)) { + const result = await schema['~standard'].validate(data); + if (result.issues) { + return { success: false, error: new SchemaError(result.issues) }; + } + return { success: true, data: result.value as SchemaOutput }; + } + if (isZ4Schema(schema)) { // Mini exposes top-level safeParseAsync const result = await z4mini.safeParseAsync(schema, data); return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; } - const v3Schema = schema as z3.ZodTypeAny; - const result = await v3Schema.safeParseAsync(data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + + if (isZodSchema(schema)) { + const v3Schema = schema as z3.ZodTypeAny; + const result = await v3Schema.safeParseAsync(data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + } + + return { success: false, error: new Error('Unsupported schema type') }; } // --- Shape extraction --- export function getObjectShape(schema: AnyObjectSchema | undefined): Record | undefined { if (!schema) return undefined; + if (isStandardSchema(schema) && (schema as { _shape?: Record })._shape) { + return (schema as { _shape?: Record })._shape; + } + // Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape` let rawShape: Record | (() => Record) | undefined; @@ -132,57 +286,47 @@ export function getObjectShape(schema: AnyObjectSchema | undefined): Record) - // Raw shapes don't have _def or _zod properties and aren't schemas themselves - if (typeof schema === 'object') { - // Check if it's actually a ZodRawShapeCompat (not a schema instance) - // by checking if it lacks schema-like internal properties - const asV3 = schema as unknown as ZodV3Internal; - const asV4 = schema as unknown as ZodV4Internal; - - // If it's not a schema instance (no _def or _zod), it might be a raw shape - if (!asV3._def && !asV4._zod) { - // Check if all values are schemas (heuristic to confirm it's a raw shape) - const values = Object.values(schema); - if ( - values.length > 0 && - values.every( - v => - typeof v === 'object' && - v !== null && - ((v as unknown as ZodV3Internal)._def !== undefined || - (v as unknown as ZodV4Internal)._zod !== undefined || - typeof (v as { parse?: unknown }).parse === 'function') - ) - ) { - return objectFromShape(schema as ZodRawShapeCompat); - } - } + if (isRawShapeCompat(schema)) { + return objectFromShape(schema); } // If we get here, it should be an AnySchema (not a raw shape) // Check if it's already an object schema - if (isZ4Schema(schema as AnySchema)) { + const maybeSchema = schema as AnySchema; + if (isZ4Schema(maybeSchema)) { // Check if it's a v4 object - const v4Schema = schema as unknown as ZodV4Internal; + const v4Schema = maybeSchema as unknown as ZodV4Internal; const def = v4Schema._zod?.def; if (def && (def.typeName === 'object' || def.shape !== undefined)) { - return schema as AnyObjectSchema; + return maybeSchema as unknown as AnyObjectSchema; } - } else { + } else if (isZodSchema(maybeSchema)) { // Check if it's a v3 object - const v3Schema = schema as unknown as ZodV3Internal; + const v3Schema = maybeSchema as unknown as ZodV3Internal; if (v3Schema.shape !== undefined) { - return schema as AnyObjectSchema; + return maybeSchema as unknown as AnyObjectSchema; } } @@ -192,18 +336,34 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un // --- Error message extraction --- /** * Safely extracts an error message from a parse result error. - * Zod errors can have different structures, so we handle various cases. + * Handles Standard Schema issues, SchemaError, and Zod-style errors. */ export function getParseErrorMessage(error: unknown): string { if (error && typeof error === 'object') { - // Try common error structures - if ('message' in error && typeof error.message === 'string') { + // Standard Schema issues array + if (Array.isArray(error) && error.length > 0) { + const first = error[0]; + if (first && typeof first === 'object' && 'message' in first) { + return String((first as { message: unknown }).message); + } + } + + if (error instanceof SchemaError) { return error.message; } - if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { - const firstIssue = error.issues[0]; + + // Try common error structures + if ('message' in error && typeof (error as { message: unknown }).message === 'string') { + return (error as { message: string }).message; + } + if ( + 'issues' in error && + Array.isArray((error as { issues: unknown }).issues) && + (error as { issues: unknown[] }).issues.length > 0 + ) { + const firstIssue = (error as { issues: unknown[] }).issues[0]; if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) { - return String(firstIssue.message); + return String((firstIssue as { message: unknown }).message); } } // Fallback: try to stringify the error @@ -219,33 +379,45 @@ export function getParseErrorMessage(error: unknown): string { // --- Schema metadata access --- /** * Gets the description from a schema, if available. - * Works with both Zod v3 and v4. + * Works with both Zod v3 and v4. Returns undefined for generic Standard Schema. */ export function getSchemaDescription(schema: AnySchema): string | undefined { if (isZ4Schema(schema)) { const v4Schema = schema as unknown as ZodV4Internal; return v4Schema._zod?.def?.description; } - const v3Schema = schema as unknown as ZodV3Internal; - // v3 may have description on the schema itself or in _def - return (schema as { description?: string }).description ?? v3Schema._def?.description; + if (isZodSchema(schema)) { + const v3Schema = schema as unknown as ZodV3Internal; + // v3 may have description on the schema itself or in _def + return (schema as { description?: string }).description ?? v3Schema._def?.description; + } + return undefined; } /** * Checks if a schema is optional. - * Works with both Zod v3 and v4. + * Works with both Zod v3 and v4. For Standard Schema, attempts a lightweight check using undefined. */ export function isSchemaOptional(schema: AnySchema): boolean { if (isZ4Schema(schema)) { const v4Schema = schema as unknown as ZodV4Internal; return v4Schema._zod?.def?.typeName === 'ZodOptional'; } - const v3Schema = schema as unknown as ZodV3Internal; - // v3 has isOptional() method - if (typeof (schema as { isOptional?: () => boolean }).isOptional === 'function') { - return (schema as { isOptional: () => boolean }).isOptional(); + if (isZodSchema(schema)) { + const v3Schema = schema as unknown as ZodV3Internal; + // v3 has isOptional() method + if (typeof (schema as { isOptional?: () => boolean }).isOptional === 'function') { + return (schema as { isOptional: () => boolean }).isOptional(); + } + return v3Schema._def?.typeName === 'ZodOptional'; } - return v3Schema._def?.typeName === 'ZodOptional'; + if (isStandardSchema(schema)) { + const result = schema['~standard'].validate(undefined); + if (!(result instanceof Promise)) { + return !result.issues; + } + } + return false; } /** @@ -264,17 +436,18 @@ export function getLiteralValue(schema: AnySchema): unknown { return def.values[0]; } } - } - const v3Schema = schema as unknown as ZodV3Internal; - const def = v3Schema._def; - if (def) { - if (def.value !== undefined) return def.value; - if (Array.isArray(def.values) && def.values.length > 0) { - return def.values[0]; + } else if (isZodSchema(schema)) { + const v3Schema = schema as unknown as ZodV3Internal; + const def = v3Schema._def; + if (def) { + if (def.value !== undefined) return def.value; + if (Array.isArray(def.values) && def.values.length > 0) { + return def.values[0]; + } } + // Fallback: check for direct value property (some Zod versions) + const directValue = (schema as { value?: unknown }).value; + if (directValue !== undefined) return directValue; } - // Fallback: check for direct value property (some Zod versions) - const directValue = (schema as { value?: unknown }).value; - if (directValue !== undefined) return directValue; return undefined; } diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index cde66b177..bbed05411 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -6,13 +6,16 @@ import type * as z3 from 'zod/v3'; import type * as z4c from 'zod/v4/core'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { JsonSchema7Type } from 'zod-to-json-schema'; import * as z4mini from 'zod/v4-mini'; import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import { toJsonSchema as toStandardJsonSchema } from '@standard-community/standard-json'; -type JsonSchema = Record; +type JsonSchema = JsonSchema7Type; // Options accepted by call sites; we map them appropriately type CommonOpts = { @@ -28,7 +31,13 @@ function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft- return 'draft-7'; // fallback } -export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { +export async function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): Promise { + const standard = (schema as { ['~standard']?: StandardSchemaV1['~standard'] })['~standard']; + if (standard && standard.vendor && standard.vendor !== 'zod') { + const converted = toStandardJsonSchema(schema as unknown as StandardSchemaV1); + return converted instanceof Promise ? await converted : converted; + } + if (isZ4Schema(schema)) { // v4 branch — use Mini's built-in toJSONSchema return z4mini.toJSONSchema(schema as z4c.$ZodType, {