diff --git a/.changeset/zod-fallback-description-recovery.md b/.changeset/zod-fallback-description-recovery.md new file mode 100644 index 0000000000..0951438391 --- /dev/null +++ b/.changeset/zod-fallback-description-recovery.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Recover `.describe()` descriptions when converting schemas from zod versions without `~standard.jsonSchema` (zod 4.0/4.1 and the zod@3.25.x `zod/v4` subpath). The bundled-converter fallback previously dropped all registry-held metadata, silently advertising tool schemas without +any field documentation. The fallback warning can now be silenced with `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1` and mentions what is and isn't preserved. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..eb03814e00 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -225,6 +225,14 @@ The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the **IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**Zod version matrix** for `inputSchema`/`outputSchema`/`argsSchema` values: + +| Schema's zod lineage | Behavior in v2 | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| zod >=4.2 (`~standard.jsonSchema` present) | Native conversion, full fidelity | +| zod 4.0 / 4.1, zod@3.25.x `zod/v4` subpath | Bundled-converter fallback + one-time warning (silence: `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1`); `.describe()` recovered best-effort, `.meta()` may be lost | +| zod 3 classic (`zod` import from zod@3.x) | Error at `tools/list` — upgrade to zod 4 or use `fromJsonSchema()` | + ### Tools ```typescript diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..2bbd545870 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -313,6 +313,34 @@ This applies to: | `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | +#### Using zod 4.0 / 4.1 (or the zod@3.25.x `zod/v4` subpath) + +`~standard.jsonSchema` was added in zod 4.2.0. Older zod 4 lineages implement Standard Schema validation but not JSON Schema conversion, so the SDK falls back to converting your schema with its own bundled zod. Because zod stores `.describe()` text in a per-instance metadata +registry, the bundled converter cannot see metadata attached through your zod instance — the SDK recovers `.describe()` descriptions on a best-effort basis (top level, object properties, array elements, and `.optional()`/`.nullable()`/`.default()` wrappers), but other registry +metadata attached via `.meta()` may be lost. + +The fallback logs a one-time warning. Silence it with `MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1`, or upgrade to zod >=4.2.0 for full fidelity. If upgrading isn't an option, you can supply the converter from your own zod instance, which preserves all metadata: + +```typescript +import * as z from 'zod/v4'; // your zod, any 4.x lineage + +function withJsonSchema(schema: T) { + return { + '~standard': { + ...schema['~standard'], + jsonSchema: { + input: () => z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'input' }), + output: () => z.toJSONSchema(schema, { target: 'draft-2020-12', io: 'output' }) + } + } + }; +} + +server.registerTool('greet', { inputSchema: withJsonSchema(z.object({ name: z.string().describe('who to greet') })) }, handler); +``` + +zod 3 schemas (the classic `zod` import from zod@3.x) cannot be converted at all and produce a clear error at `tools/list` time — upgrade to zod 4, or describe your input with `fromJsonSchema()` instead. + ### Host header validation moved Express-specific middleware (`hostHeaderValidation()`, `localhostHostValidation()`) moved from the server package to `@modelcontextprotocol/express`. The server package now exports framework-agnostic functions instead: `validateHostHeader()`, `localhostAllowedHostnames()`, diff --git a/packages/core/package.json b/packages/core/package.json index beb46ccb88..58becbb000 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,13 +77,11 @@ } }, "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", "@cfworker/json-schema": "catalog:runtimeShared", - "ajv": "catalog:runtimeShared", - "ajv-formats": "catalog:runtimeShared", "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", @@ -91,6 +89,8 @@ "@types/express": "catalog:devTools", "@types/express-serve-static-core": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", @@ -98,6 +98,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b938885de0..b717afb3d6 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -164,6 +164,131 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch let warnedZodFallback = false; +function isZodFallbackWarningSuppressed(): boolean { + // Core must stay runtime-neutral (browser / Workers), so reach for `process` defensively. + try { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + const value = env?.MCP_SUPPRESS_ZOD_FALLBACK_WARNING; + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; + } catch { + return false; + } +} + +function readForeignDescription(node: unknown): string | undefined { + // `.description` is a getter that runs the schema's own zod code against its own + // metadata registry, so it works across zod instances where the bundled converter + // cannot. Foreign getters are untrusted: never let them break conversion. + try { + const description = (node as { description?: unknown }).description; + return typeof description === 'string' && description.length > 0 ? description : undefined; + } catch { + return undefined; + } +} + +function unwrapForeignSchema(node: unknown): unknown { + // Wrappers like .optional()/.nullable()/.default() carry their own registry entry; + // a .describe() applied before wrapping lives on the inner schema instead. + try { + const def = (node as { _zod?: { def?: { innerType?: unknown } } })._zod?.def; + return def?.innerType; + } catch { + return undefined; + } +} + +function readForeignDescriptionDeep(node: unknown): string | undefined { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + const description = readForeignDescription(current); + if (description !== undefined) return description; + current = unwrapForeignSchema(current); + } + return undefined; +} + +function foreignShape(node: unknown): Record | undefined { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + try { + const shape = (current as { shape?: unknown }).shape; + if (shape != null && typeof shape === 'object') return shape as Record; + } catch { + return undefined; + } + current = unwrapForeignSchema(current); + } + return undefined; +} + +function foreignElement(node: unknown): unknown { + let current: unknown = node; + for (let depth = 0; depth < 8 && current != null; depth++) { + try { + const element = (current as { element?: unknown }).element; + if (element != null) return element; + } catch { + return undefined; + } + current = unwrapForeignSchema(current); + } + return undefined; +} + +/** + * Best-effort recovery of `.describe()` metadata after converting a foreign zod + * instance's schema with the SDK-bundled `z.toJSONSchema()`. + * + * Zod stores `.describe()` text in a per-instance metadata registry, so the bundled + * converter silently drops every description attached through a different zod instance + * (zod 4.0/4.1, or the zod@3.25.x `zod/v4` subpath). The schema's own `.description` + * getters still work, so walk the schema alongside the converted JSON Schema and fill + * in any descriptions the converter missed. Existing descriptions are never overwritten. + */ +function recoverForeignDescriptions(schema: unknown, jsonSchema: Record, path = new WeakSet(), depth = 0): void { + if (depth > 16 || schema == null || typeof schema !== 'object') return; + // Cycle detection is scoped to the current recursion path: a schema instance that is + // *reused* at several positions (sibling properties, nested objects) is converted to a + // distinct JSON Schema node per occurrence and must be recovered at every one of them. + // Only a true ancestor cycle stops the walk; the depth cap bounds everything else. + if (path.has(schema)) return; + path.add(schema); + try { + recoverNode(schema, jsonSchema, path, depth); + } finally { + path.delete(schema); + } +} + +function recoverNode(schema: object, jsonSchema: Record, path: WeakSet, depth: number): void { + if (jsonSchema.description === undefined) { + const description = readForeignDescriptionDeep(schema); + if (description !== undefined) jsonSchema.description = description; + } + + const properties = jsonSchema.properties; + if (properties != null && typeof properties === 'object') { + const shape = foreignShape(schema); + if (shape) { + for (const [key, fieldSchema] of Object.entries(shape)) { + const fieldJson = (properties as Record)[key]; + if (fieldJson != null && typeof fieldJson === 'object') { + recoverForeignDescriptions(fieldSchema, fieldJson as Record, path, depth + 1); + } + } + } + } + + const items = jsonSchema.items; + if (items != null && typeof items === 'object' && !Array.isArray(items)) { + const element = foreignElement(schema); + if (element != null) { + recoverForeignDescriptions(element, items as Record, path, depth + 1); + } + } +} + /** * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. * @@ -190,14 +315,17 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' ); } - if (!warnedZodFallback) { + if (!warnedZodFallback && !isZodFallbackWarningSuppressed()) { warnedZodFallback = true; console.warn( '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + - 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' + 'Falling back to the bundled converter; `.describe()` descriptions are recovered on a best-effort ' + + 'basis but other registry metadata (`.meta()`) may be lost. Upgrade to zod >=4.2.0 for full ' + + 'fidelity, or set MCP_SUPPRESS_ZOD_FALLBACK_WARNING=1 to silence this warning.' ); } result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record; + recoverForeignDescriptions(schema, result); } else { throw new Error( `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + diff --git a/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts new file mode 100644 index 0000000000..f1ea7a4fa6 --- /dev/null +++ b/packages/core/test/util/standardSchema.zodForeignDescriptions.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as zOld from 'zod-v40'; +import * as z from 'zod/v4'; + +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; + +type SchemaArg = Parameters[0]; + +// `zod-v40` is an npm alias for zod@4.0.x: a real second zod instance that implements +// StandardSchemaV1 but not `~standard.jsonSchema` (added in 4.2). Because zod stores +// `.describe()` text in a per-instance global registry, the SDK-bundled `z.toJSONSchema()` +// cannot see any metadata attached through a foreign instance — exactly the situation of +// an application that depends on zod 4.0/4.1 (or the zod@3.25.x `zod/v4` subpath) while +// the SDK bundles its own zod. These tests pin the fallback's behavior for that case. + +function props(result: Record): Record { + return result.properties as Record; +} + +describe('standardSchemaToJsonSchema — foreign zod instance description recovery', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('recovers .describe() metadata that the bundled converter cannot see', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const schema = zOld + .object({ + name: zOld.string().describe('the display name'), + age: zOld.number().optional().describe('age in years'), + nickname: zOld.string().describe('preferred short name').optional(), + address: zOld + .object({ + street: zOld.string().describe('street and house number') + }) + .describe('postal address'), + tags: zOld.array(zOld.string().describe('a single tag')).describe('all tags') + }) + .describe('a person'); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + expect(result.description).toBe('a person'); + expect(props(result).name?.description).toBe('the display name'); + // .describe() applied after .optional() (registry entry on the wrapper) + expect(props(result).age?.description).toBe('age in years'); + // .describe() applied before .optional() (registry entry on the inner schema) + expect(props(result).nickname?.description).toBe('preferred short name'); + expect(props(result).address?.description).toBe('postal address'); + const address = props(result).address as { properties?: Record }; + expect(address.properties?.street?.description).toBe('street and house number'); + expect(props(result).tags?.description).toBe('all tags'); + expect(props(result).tags?.items?.description).toBe('a single tag'); + }); + + it('does not overwrite descriptions the converter already produced', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Bundled-instance schema with `~standard.jsonSchema` hidden: the converter shares + // the schema's registry, so its own description survives conversion and must win. + const real = z.object({ a: z.string().describe('from converter') }); + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + // Shadow the field's `.description` getter with a value that DIFFERS from what the + // converter emits (the converter reads the registry, not this own-property), so the + // assertion can actually distinguish "preserved" from "overwritten with the same text". + Object.defineProperty(real.shape.a, 'description', { get: () => 'from recovery walk', configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + expect(props(result).a?.description).toBe('from converter'); + }); + + it('recovery never throws, even when foreign getters do', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const schema = zOld.object({ a: zOld.string() }); + Object.defineProperty(schema, 'description', { + get() { + throw new Error('hostile getter'); + }, + configurable: true + }); + + expect(() => standardSchemaToJsonSchema(schema as unknown as SchemaArg)).not.toThrow(); + }); + + it('recovers descriptions for a schema instance reused at multiple positions', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // One schema instance referenced from several places: the converter inlines a + // distinct JSON Schema node per occurrence, so every occurrence needs recovery, + // not just the first one the walk happens to visit. + const tag = zOld.string().describe('a tag'); + const schema = zOld.object({ + primary: tag, + secondary: tag, + nested: zOld.object({ inner: tag }).describe('nested holder') + }); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + expect(props(result).primary?.description).toBe('a tag'); + expect(props(result).secondary?.description).toBe('a tag'); + const nested = props(result).nested as { description?: string; properties?: Record }; + expect(nested.description).toBe('nested holder'); + expect(nested.properties?.inner?.description).toBe('a tag'); + }); + + it('recovers descriptions for a reused object schema and its children', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const address = zOld.object({ street: zOld.string().describe('street and number') }).describe('postal address'); + const schema = zOld.object({ home: address, work: address }); + + const result = standardSchemaToJsonSchema(schema as unknown as SchemaArg); + + for (const key of ['home', 'work'] as const) { + const node = props(result)[key] as { description?: string; properties?: Record }; + expect(node.description).toBe('postal address'); + expect(node.properties?.street?.description).toBe('street and number'); + } + }); + + it('terminates on cyclic schema graphs', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // The walk duck-types foreign schemas (.description / .shape), so a self-referential + // graph is representable regardless of zod version. Recovery must stop at the cycle + // along the current path (with the depth cap as backstop), not recurse forever. + const cyclicSchema: { description: string; shape: Record } = { + description: 'a node', + shape: {} + }; + cyclicSchema.shape.self = cyclicSchema; + const cyclicJson: Record = { type: 'object', properties: {} }; + (cyclicJson.properties as Record).self = cyclicJson; + + // The bundled converter never emits cyclic output, so substitute it for this test + // to hand the recovery walk a worst-case graph on both sides. + vi.resetModules(); + vi.doMock('zod/v4', async importOriginal => { + const real = await importOriginal(); + return { ...real, toJSONSchema: () => cyclicJson }; + }); + try { + const { standardSchemaToJsonSchema: convert } = await import('../../src/util/standardSchema.js'); + const fake = { + '~standard': { version: 1, vendor: 'zod', validate: (value: unknown) => ({ value }) }, + _zod: {}, + description: cyclicSchema.description, + shape: cyclicSchema.shape + }; + const result = convert(fake as unknown as SchemaArg) as Record; + expect(result.description).toBe('a node'); + } finally { + vi.doUnmock('zod/v4'); + vi.resetModules(); + } + }); + + it('zod 3 schemas still get the clear upgrade error', () => { + const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; + expect(() => standardSchemaToJsonSchema(zod3ish as unknown as SchemaArg)).toThrow(/zod 3/); + }); +}); + +describe('standardSchemaToJsonSchema — fallback warning controls', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + async function freshConvert(schema: unknown): Promise { + // Fresh module instance so the once-per-process warning flag is reset. + vi.resetModules(); + const warnings: string[] = []; + const warn = vi.spyOn(console, 'warn').mockImplementation((msg: unknown) => { + warnings.push(String(msg)); + }); + const mod = await import('../../src/util/standardSchema.js'); + mod.standardSchemaToJsonSchema(schema as Parameters[0]); + mod.standardSchemaToJsonSchema(schema as Parameters[0]); + warn.mockRestore(); + return warnings; + } + + it('warns once per process and points at zod 4.2.0', async () => { + const warnings = await freshConvert(zOld.object({ a: zOld.string() })); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('zod 4.2.0'); + expect(warnings[0]).toContain('MCP_SUPPRESS_ZOD_FALLBACK_WARNING'); + }); + + it('is silenced by MCP_SUPPRESS_ZOD_FALLBACK_WARNING', async () => { + vi.stubEnv('MCP_SUPPRESS_ZOD_FALLBACK_WARNING', '1'); + const warnings = await freshConvert(zOld.object({ a: zOld.string() })); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index d7bce70fa4..c5d3334de5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -112,6 +112,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/server/test/server/mcp.foreignZodDescriptions.test.ts b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts new file mode 100644 index 0000000000..7db38ae884 --- /dev/null +++ b/packages/server/test/server/mcp.foreignZodDescriptions.test.ts @@ -0,0 +1,100 @@ +import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as zOld from 'zod-v40'; + +import { McpServer } from '../../src/index.js'; + +// `zod-v40` is an npm alias for zod@4.0.x: a real second zod instance that implements +// StandardSchemaV1 but not `~standard.jsonSchema` (added in 4.2). An application that +// registers tools with schemas from its own zod 4.0/4.1 (or the zod@3.25.x `zod/v4` +// subpath) hits the bundled-converter fallback when the tool list is served. This test +// pins the end-to-end behavior a connected peer observes: the advertised tool schema +// retains `.describe()` metadata, and validated tool calls still round-trip. + +type ProfileInput = { name: string; address: { street: string }; primaryLabel?: string; secondaryLabel?: string }; + +describe('tools/list over a transport with a foreign-zod inputSchema', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('advertises .describe() descriptions and validates calls end-to-end', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const server = new McpServer({ name: 't', version: '1.0.0' }); + const label = zOld.string().describe('a short label').optional(); + let received: unknown; + server.registerTool( + 'create-profile', + { + description: 'creates a profile', + inputSchema: zOld + .object({ + name: zOld.string().describe('the display name'), + address: zOld.object({ street: zOld.string().describe('street and house number') }).describe('postal address'), + // One schema instance reused at two positions: both occurrences + // must carry the description in the advertised schema. + primaryLabel: label, + secondaryLabel: label + }) + .describe('profile input') as unknown as StandardSchemaWithJSON + }, + async args => { + received = args; + return { content: [{ type: 'text' as const, text: 'ok' }] }; + } + ); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const list = responses.find(r => 'id' in r && r.id === 2) as { + result?: { tools: Array<{ name: string; inputSchema: Record }> }; + }; + const tool = list.result?.tools.find(t => t.name === 'create-profile'); + expect(tool).toBeDefined(); + const schema = tool?.inputSchema as { + description?: string; + properties?: Record }>; + }; + // What the connected peer actually sees: descriptions survive the fallback conversion. + expect(schema.description).toBe('profile input'); + expect(schema.properties?.name?.description).toBe('the display name'); + expect(schema.properties?.address?.description).toBe('postal address'); + expect(schema.properties?.address?.properties?.street?.description).toBe('street and house number'); + expect(schema.properties?.primaryLabel?.description).toBe('a short label'); + expect(schema.properties?.secondaryLabel?.description).toBe('a short label'); + + // The foreign schema also still validates calls (cross-instance `~standard.validate`). + await client.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'create-profile', arguments: { name: 'Ada', address: { street: 'Main St 1' } } } + } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 3)).toBe(true)); + expect(received).toEqual({ name: 'Ada', address: { street: 'Main St 1' } }); + + await server.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c13f1ebcde..410640d13b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/middleware/express: dependencies: @@ -1007,6 +1010,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/server-legacy: dependencies: @@ -5319,6 +5325,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -9360,4 +9369,6 @@ snapshots: dependencies: zod: 4.3.6 + zod@4.0.17: {} + zod@4.3.6: {}