From 5f4253ec215bb95620edbb388f3d15cf2a01fb9e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:51:35 +0000 Subject: [PATCH 1/7] Add parseSpecType and safeParseSpecType validation helpers v2 removed the exported Zod schemas, so v1 call sites using SomeTypeSchema.parse(value) / .safeParse(value) currently migrate to specTypeSchemas.X['~standard'].validate(value) plus a manual result remap (.success -> .issues === undefined, .data -> .value), which is verbose and easy to get wrong. These helpers restore the familiar parse/safeParse shapes on top of the existing spec type registry: - parseSpecType(name, value) returns the parsed value or throws SpecTypeValidationError (message summarizes issues; .issues holds the structured failures, mirroring ZodError usage) - safeParseSpecType(name, value) returns { success: true, data } | { success: false, issues } Both are synchronous (the backing schemas are StandardSchemaV1Sync) and keyed by SpecTypeName for autocomplete and typo checking. Also updates both migration guides to recommend them as the primary replacement for v1 schema parse/safeParse call sites. --- .changeset/spec-type-parse-helpers.md | 5 ++ docs/migration-SKILL.md | 17 ++-- docs/migration.md | 21 +++-- packages/core/src/exports/public/index.ts | 4 +- .../core/src/types/specTypeSchema.examples.ts | 24 +++++- packages/core/src/types/specTypeSchema.ts | 80 ++++++++++++++++++ .../core/test/types/specTypeSchema.test.ts | 83 ++++++++++++++++++- 7 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 .changeset/spec-type-parse-helpers.md diff --git a/.changeset/spec-type-parse-helpers.md b/.changeset/spec-type-parse-helpers.md new file mode 100644 index 0000000000..00ae5d4957 --- /dev/null +++ b/.changeset/spec-type-parse-helpers.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': minor +--- + +Add `parseSpecType` and `safeParseSpecType` helpers (exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) as drop-in shaped replacements for v1's `Schema.parse()` / `.safeParse()`. `parseSpecType('CallToolResult', value)` returns the parsed value or throws `SpecTypeValidationError` (with `.issues`); `safeParseSpecType` returns a `{ success, data | issues }` discriminated union so migrated call sites keep their control flow. Both are synchronous. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..1a6b684503 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -471,16 +471,17 @@ For **custom (non-spec)** methods, keep the result-schema argument — see §9. Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. -If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`: +If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `parseSpecType` / `safeParseSpecType` / `isSpecType`: -| v1 pattern | v2 replacement | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | -| `Schema.safeParse(value).success` | `isSpecType.(value)` | -| `Schema.parse(value)` | `specTypeSchemas.['~standard'].validate(value)` (returns a `Result` synchronously, not the value) | -| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | +| v1 pattern | v2 replacement | +| -------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `Schema.parse(value)` | `parseSpecType('', value)` (returns the parsed value; throws `SpecTypeValidationError` with `.issues`) | +| `Schema.safeParse(value)` | `safeParseSpecType('', value)` (`{ success: true, data }` \| `{ success: false, issues }`) | +| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | +| `Schema.safeParse(value).success` | `isSpecType.(value)` | +| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | -`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. +All validation is synchronous (`StandardSchemaV1Sync`) — never add `await`. `isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. ## 12. Experimental tasks interception removed diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..9cd8a481db 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -506,29 +506,32 @@ The return type is now inferred from the method name via `ResultTypeMap`. For ex For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. -If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType` or `specTypeSchemas`: +If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `parseSpecType` / `safeParseSpecType` — drop-in shaped replacements for `.parse()` / `.safeParse()` — or `isSpecType` for a boolean guard: ```typescript // v1: runtime validation with Zod schema import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -if (CallToolResultSchema.safeParse(value).success) { - /* ... */ -} +const result = CallToolResultSchema.parse(value); // throws ZodError on failure +const parsed = CallToolResultSchema.safeParse(value); // { success, data | error } + +// v2: drop-in shaped replacements +import { parseSpecType, safeParseSpecType } from '@modelcontextprotocol/client'; +const result = parseSpecType('CallToolResult', value); // throws SpecTypeValidationError (with .issues) on failure +const parsed = safeParseSpecType('CallToolResult', value); // { success: true, data } | { success: false, issues } -// v2: keyed type predicate +// v2: boolean type predicate import { isSpecType } from '@modelcontextprotocol/client'; if (isSpecType.CallToolResult(value)) { /* ... */ } const blocks = mixed.filter(isSpecType.ContentBlock); -// v2: or get the StandardSchemaV1Sync validator object directly +// v2: or use the StandardSchemaV1Sync validator object directly import { specTypeSchemas } from '@modelcontextprotocol/client'; -const result = specTypeSchemas.CallToolResult['~standard'].validate(value); +const r = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` -`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, -so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. +All of these are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. Validation is **synchronous** throughout: the backing schemas are `StandardSchemaV1Sync`, so `validate()` returns the result directly and no `await` is needed. `specTypeSchemas.X` composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 729144f1a3..a7f6e93788 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -106,8 +106,8 @@ export { } from '../../types/guards.js'; // Validator types and classes -export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; -export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js'; +export type { SafeParseSpecTypeResult, SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; +export { isSpecType, parseSpecType, safeParseSpecType, specTypeSchemas, SpecTypeValidationError } from '../../types/specTypeSchema.js'; export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/types/specTypeSchema.examples.ts b/packages/core/src/types/specTypeSchema.examples.ts index 8e991d4f94..2fcd531ec0 100644 --- a/packages/core/src/types/specTypeSchema.examples.ts +++ b/packages/core/src/types/specTypeSchema.examples.ts @@ -7,7 +7,7 @@ * @module */ -import { isSpecType, specTypeSchemas } from './specTypeSchema.js'; +import { isSpecType, parseSpecType, safeParseSpecType, specTypeSchemas } from './specTypeSchema.js'; declare const untrusted: unknown; declare const value: unknown; @@ -36,5 +36,27 @@ function isSpecType_basicUsage() { void blocks; } +function parseSpecType_basicUsage() { + //#region parseSpecType_basicUsage + const result = parseSpecType('CallToolResult', untrusted); + // result is CallToolResult; throws SpecTypeValidationError on invalid input + //#endregion parseSpecType_basicUsage + void result; +} + +function safeParseSpecType_basicUsage() { + //#region safeParseSpecType_basicUsage + const parsed = safeParseSpecType('Tool', untrusted); + if (parsed.success) { + // parsed.data is Tool + } else { + // parsed.issues describes the failures + } + //#endregion safeParseSpecType_basicUsage + void parsed; +} + void specTypeSchemas_basicUsage; void isSpecType_basicUsage; +void parseSpecType_basicUsage; +void safeParseSpecType_basicUsage; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 477d61a55a..488e92e5aa 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -294,3 +294,83 @@ export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as S * ``` */ export const isSpecType: GuardRecord = Object.freeze(_isSpecType as GuardRecord); + +function formatIssuePath(path: NonNullable): string { + return path + .map(segment => (typeof segment === 'object' && segment !== null && 'key' in segment ? String(segment.key) : String(segment))) + .join('.'); +} + +function formatIssues(issues: ReadonlyArray): string { + return issues.map(issue => (issue.path?.length ? `${formatIssuePath(issue.path)}: ${issue.message}` : issue.message)).join('; '); +} + +/** + * Error thrown by {@linkcode parseSpecType} when a value fails validation. + * + * Mirrors the shape v1 consumers relied on when catching `ZodError` from + * `Schema.parse()`: the failure details are available on + * {@linkcode SpecTypeValidationError.issues} and summarized in the message. + */ +export class SpecTypeValidationError extends Error { + readonly specType: SpecTypeName; + readonly issues: ReadonlyArray; + + constructor(specType: SpecTypeName, issues: ReadonlyArray) { + super(`Invalid ${specType}: ${formatIssues(issues)}`); + this.name = 'SpecTypeValidationError'; + this.specType = specType; + this.issues = issues; + } +} + +/** + * Validates that `value` is a valid instance of the named spec type and returns the parsed value, + * throwing {@linkcode SpecTypeValidationError} on failure. + * + * This is the direct replacement for v1's `Schema.parse(value)`. Validation is + * synchronous (the backing schemas are {@linkcode StandardSchemaV1Sync}), so no `await` is needed. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#parseSpecType_basicUsage" + * const result = parseSpecType('CallToolResult', untrusted); + * // result is CallToolResult; throws SpecTypeValidationError on invalid input + * ``` + */ +export function parseSpecType(name: K, value: unknown): SpecTypes[K] { + const result = specTypeSchemas[name]['~standard'].validate(value); + if (result.issues) { + throw new SpecTypeValidationError(name, result.issues); + } + return result.value; +} + +/** + * Result of {@linkcode safeParseSpecType}: a discriminated union shaped like the result of v1's + * `Schema.safeParse(value)`, so migrated call sites keep their `.success` / `.data` + * control flow. + */ +export type SafeParseSpecTypeResult = + | { readonly success: true; readonly data: T } + | { readonly success: false; readonly issues: ReadonlyArray }; + +/** + * Validates that `value` is a valid instance of the named spec type without throwing. + * + * This is the direct replacement for v1's `Schema.safeParse(value)`. Validation is + * synchronous (the backing schemas are {@linkcode StandardSchemaV1Sync}), so no `await` is needed. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#safeParseSpecType_basicUsage" + * const parsed = safeParseSpecType('Tool', untrusted); + * if (parsed.success) { + * // parsed.data is Tool + * } else { + * // parsed.issues describes the failures + * } + * ``` + */ +export function safeParseSpecType(name: K, value: unknown): SafeParseSpecTypeResult { + const result = specTypeSchemas[name]['~standard'].validate(value); + return result.issues ? { success: false, issues: result.issues } : { success: true, data: result.value }; +} diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..44053dc819 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -3,7 +3,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js'; import * as schemas from '../../src/types/schemas.js'; import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js'; -import { isSpecType, specTypeSchemas } from '../../src/types/specTypeSchema.js'; +import { isSpecType, parseSpecType, safeParseSpecType, SpecTypeValidationError, specTypeSchemas } from '../../src/types/specTypeSchema.js'; import type { CallToolResult, ContentBlock, @@ -174,3 +174,84 @@ describe('SPEC_SCHEMA_KEYS allowlist', () => { expect(actual).toEqual(expected); }); }); + +describe('parseSpecType', () => { + it('returns the parsed value for valid input', () => { + const value = parseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + expect(value).toEqual({ name: 'x', version: '1.0.0' }); + expectTypeOf(value).toEqualTypeOf(); + }); + + it('applies schema defaults to the returned value', () => { + const value = parseSpecType('CallToolResult', {}); + expect(value.content).toEqual([]); + }); + + it('throws SpecTypeValidationError with issue summaries on invalid input', () => { + expect(() => parseSpecType('Implementation', { name: 'x' })).toThrowError(SpecTypeValidationError); + try { + parseSpecType('Implementation', { name: 'x' }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SpecTypeValidationError); + const validationError = error as SpecTypeValidationError; + expect(validationError.name).toBe('SpecTypeValidationError'); + expect(validationError.specType).toBe('Implementation'); + expect(validationError.issues.length).toBeGreaterThan(0); + expect(validationError.message).toContain('Invalid Implementation'); + expect(validationError.message).toContain('version'); + } + }); + + it('is synchronous — never returns a Promise', () => { + const value: Implementation = parseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + expect(value).not.toBeInstanceOf(Promise); + }); + + it('covers OAuth types', () => { + const tokens = parseSpecType('OAuthTokens', { access_token: 'x', token_type: 'Bearer' }); + expectTypeOf(tokens).toEqualTypeOf(); + expect(tokens.access_token).toBe('x'); + }); + + it('rejects unknown spec type names at compile time', () => { + // @ts-expect-error - 'NotASpecType' is not a SpecTypeName + expect(() => parseSpecType('NotASpecType', {})).toThrowError(); + }); +}); + +describe('safeParseSpecType', () => { + it('returns { success: true, data } for valid input', () => { + const parsed = safeParseSpecType('Tool', { name: 't', inputSchema: { type: 'object' } }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expectTypeOf(parsed.data).toEqualTypeOf(); + expect(parsed.data.name).toBe('t'); + } + }); + + it('returns { success: false, issues } for invalid input', () => { + const parsed = safeParseSpecType('Tool', { inputSchema: { type: 'object' } }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.issues.length).toBeGreaterThan(0); + expect(parsed.issues[0]?.message).toBeTruthy(); + } + }); + + it('narrows the result type through the success discriminant', () => { + const parsed = safeParseSpecType('JSONRPCRequest', { jsonrpc: '2.0', id: 1, method: 'ping' }); + if (parsed.success) { + expectTypeOf(parsed.data).toEqualTypeOf(); + } else { + expectTypeOf(parsed).not.toHaveProperty('data'); + } + expect(parsed.success).toBe(true); + }); + + it('is synchronous — result is directly accessible without await', () => { + const parsed = safeParseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + expect(parsed).not.toBeInstanceOf(Promise); + expect(parsed.success).toBe(true); + }); +}); From cc29d4bf0ea3ab34649a78fff4c829d993b80e96 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 08:21:02 +0000 Subject: [PATCH 2/7] Add wire-level integration tests for parseSpecType helpers Drives a real McpServer over a linked InMemoryTransport and validates the raw tools/list and tools/call response payloads with parseSpecType and safeParseSpecType, matching the v1 pattern of parsing results received from a live server rather than hand-built fixtures. Includes a negative case proving a real wire payload is rejected when validated as a different spec type. --- .../test/server/parseSpecTypeWire.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/server/test/server/parseSpecTypeWire.test.ts diff --git a/packages/server/test/server/parseSpecTypeWire.test.ts b/packages/server/test/server/parseSpecTypeWire.test.ts new file mode 100644 index 0000000000..caca1aff31 --- /dev/null +++ b/packages/server/test/server/parseSpecTypeWire.test.ts @@ -0,0 +1,92 @@ +import type { CallToolResult, JSONRPCMessage, ListToolsResult } from '@modelcontextprotocol/core'; +import { + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + parseSpecType, + safeParseSpecType, + SpecTypeValidationError +} from '@modelcontextprotocol/core'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/index.js'; + +// Validates real wire payloads with parseSpecType/safeParseSpecType — the +// replacement for the v1 pattern of calling SomeResultSchema.parse() on a +// response received from a live server, rather than on hand-built fixtures. +describe('parseSpecType on wire data', () => { + async function roundTrip(): Promise<{ callToolResult: unknown; listToolsResult: unknown }> { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerTool( + 'add', + { description: 'adds two numbers', inputSchema: { a: z.number(), b: z.number() } }, + async ({ a, b }) => ({ + content: [{ type: 'text' as const, text: String(a + b) }], + structuredContent: undefined + }) + ); + + 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 client.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'add', arguments: { a: 2, b: 5 } } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 3)).toBe(true)); + const byId = (id: number) => responses.find(r => 'id' in r && r.id === id) as { result?: unknown } | undefined; + const listToolsResult = byId(2)?.result; + const callToolResult = byId(3)?.result; + await server.close(); + return { callToolResult, listToolsResult }; + } + + it('parses a tools/call result received over a real transport', async () => { + const { callToolResult } = await roundTrip(); + + const parsed = parseSpecType('CallToolResult', callToolResult); + expectTypeOf(parsed).toEqualTypeOf(); + expect(parsed.content).toEqual([{ type: 'text', text: '7' }]); + }); + + it('parses a tools/list result received over a real transport, preserving advertised schema', async () => { + const { listToolsResult } = await roundTrip(); + + const parsed = parseSpecType('ListToolsResult', listToolsResult); + expectTypeOf(parsed).toEqualTypeOf(); + expect(parsed.tools).toHaveLength(1); + expect(parsed.tools[0]?.name).toBe('add'); + expect(parsed.tools[0]?.description).toBe('adds two numbers'); + expect(parsed.tools[0]?.inputSchema.type).toBe('object'); + }); + + it('rejects the same wire payload when validated as a different spec type', async () => { + const { callToolResult } = await roundTrip(); + + expect(() => parseSpecType('Implementation', callToolResult)).toThrowError(SpecTypeValidationError); + const parsed = safeParseSpecType('Implementation', callToolResult); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.issues.length).toBeGreaterThan(0); + } + }); +}); From c0b5827a4679c493dd794b62583fd868479ad2ce Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 09:45:47 +0000 Subject: [PATCH 3/7] Move parse/safeParse onto the specTypeSchemas entries Review feedback on the string-keyed parseSpecType(name, value) helpers: property access fits the specTypeSchemas/isSpecType family better and avoids name-string arguments entirely. Each registry entry is now an SDK-owned frozen wrapper exposing the entry's Standard Schema interface plus v1-shaped parse/safeParse methods, so v1 call sites migrate with a one-line rename: CallToolResultSchema.parse(v) becomes specTypeSchemas.CallToolResult.parse(v). parse throws SpecTypeValidationError (unchanged); safeParse returns the same { success, data | issues } union as before. The top-level parseSpecType/safeParseSpecType functions are removed (never released). ~standard is delegated lazily to the backing schema, preserving the library's lazy initialization and the documented validate mechanism. --- .changeset/spec-type-parse-helpers.md | 2 +- docs/migration-SKILL.md | 8 +- docs/migration.md | 15 +- packages/core/src/exports/public/index.ts | 4 +- .../core/src/types/specTypeSchema.examples.ts | 56 ++--- packages/core/src/types/specTypeSchema.ts | 204 ++++++++++-------- .../core/test/types/specTypeSchema.test.ts | 67 ++++-- ...t.ts => specTypeSchemaMethodsWire.test.ts} | 20 +- 8 files changed, 216 insertions(+), 160 deletions(-) rename packages/server/test/server/{parseSpecTypeWire.test.ts => specTypeSchemaMethodsWire.test.ts} (84%) diff --git a/.changeset/spec-type-parse-helpers.md b/.changeset/spec-type-parse-helpers.md index 00ae5d4957..15437a5e8e 100644 --- a/.changeset/spec-type-parse-helpers.md +++ b/.changeset/spec-type-parse-helpers.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/core': minor --- -Add `parseSpecType` and `safeParseSpecType` helpers (exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) as drop-in shaped replacements for v1's `Schema.parse()` / `.safeParse()`. `parseSpecType('CallToolResult', value)` returns the parsed value or throws `SpecTypeValidationError` (with `.issues`); `safeParseSpecType` returns a `{ success, data | issues }` discriminated union so migrated call sites keep their control flow. Both are synchronous. +Add v1-style `parse`/`safeParse` methods to every `specTypeSchemas` entry, so v1 call sites migrate with a one-line rename: `CallToolResultSchema.parse(value)` becomes `specTypeSchemas.CallToolResult.parse(value)`. `parse` returns the parsed value or throws `SpecTypeValidationError` (with `.issues`); `safeParse` returns a `{ success, data | issues }` discriminated union so migrated call sites keep their control flow. Both are synchronous, and each entry remains a Standard Schema (`['~standard'].validate`). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 1a6b684503..220ff30513 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -471,15 +471,15 @@ For **custom (non-spec)** methods, keep the result-schema argument — see §9. Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. -If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `parseSpecType` / `safeParseSpecType` / `isSpecType`: +If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), the `specTypeSchemas.` entries carry `parse`/`safeParse` methods — a mechanical rename: | v1 pattern | v2 replacement | | -------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `Schema.parse(value)` | `parseSpecType('', value)` (returns the parsed value; throws `SpecTypeValidationError` with `.issues`) | -| `Schema.safeParse(value)` | `safeParseSpecType('', value)` (`{ success: true, data }` \| `{ success: false, issues }`) | +| `Schema.parse(value)` | `specTypeSchemas..parse(value)` (returns the parsed value; throws `SpecTypeValidationError` with `.issues`) | +| `Schema.safeParse(value)` | `specTypeSchemas..safeParse(value)` (`{ success: true, data }` \| `{ success: false, issues }`) | | `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | | `Schema.safeParse(value).success` | `isSpecType.(value)` | -| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | +| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync` with `parse`/`safeParse`) | All validation is synchronous (`StandardSchemaV1Sync`) — never add `await`. `isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. diff --git a/docs/migration.md b/docs/migration.md index 9cd8a481db..6ceb8c3ab5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -506,7 +506,7 @@ The return type is now inferred from the method name via `ResultTypeMap`. For ex For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. -If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `parseSpecType` / `safeParseSpecType` — drop-in shaped replacements for `.parse()` / `.safeParse()` — or `isSpecType` for a boolean guard: +If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), the `specTypeSchemas` entries carry `parse`/`safeParse` methods shaped like the v1 schemas, so the migration is a one-line rename — or use `isSpecType` for a boolean guard: ```typescript // v1: runtime validation with Zod schema @@ -514,10 +514,10 @@ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; const result = CallToolResultSchema.parse(value); // throws ZodError on failure const parsed = CallToolResultSchema.safeParse(value); // { success, data | error } -// v2: drop-in shaped replacements -import { parseSpecType, safeParseSpecType } from '@modelcontextprotocol/client'; -const result = parseSpecType('CallToolResult', value); // throws SpecTypeValidationError (with .issues) on failure -const parsed = safeParseSpecType('CallToolResult', value); // { success: true, data } | { success: false, issues } +// v2: same call shapes on the spec type registry +import { specTypeSchemas } from '@modelcontextprotocol/client'; +const result = specTypeSchemas.CallToolResult.parse(value); // throws SpecTypeValidationError (with .issues) on failure +const parsed = specTypeSchemas.CallToolResult.safeParse(value); // { success: true, data } | { success: false, issues } // v2: boolean type predicate import { isSpecType } from '@modelcontextprotocol/client'; @@ -526,12 +526,11 @@ if (isSpecType.CallToolResult(value)) { } const blocks = mixed.filter(isSpecType.ContentBlock); -// v2: or use the StandardSchemaV1Sync validator object directly -import { specTypeSchemas } from '@modelcontextprotocol/client'; +// v2: each entry is also a Standard Schema, for Standard-Schema-aware libraries const r = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` -All of these are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. Validation is **synchronous** throughout: the backing schemas are `StandardSchemaV1Sync`, so `validate()` returns the result directly and no `await` is needed. `specTypeSchemas.X` composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. +All of these are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. Validation is **synchronous** throughout: the backing schemas are `StandardSchemaV1Sync`, so `parse`/`safeParse`/`validate()` return results directly and no `await` is needed. On failure, `parse` throws `SpecTypeValidationError`, whose `.issues` plays the role v1's `ZodError.issues` did in catch blocks. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index a7f6e93788..37236782f1 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -106,8 +106,8 @@ export { } from '../../types/guards.js'; // Validator types and classes -export type { SafeParseSpecTypeResult, SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; -export { isSpecType, parseSpecType, safeParseSpecType, specTypeSchemas, SpecTypeValidationError } from '../../types/specTypeSchema.js'; +export type { SafeParseSpecTypeResult, SpecTypeName, SpecTypes, SpecTypeSchema } from '../../types/specTypeSchema.js'; +export { isSpecType, specTypeSchemas, SpecTypeValidationError } from '../../types/specTypeSchema.js'; export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema.js'; // Validator providers are type-only here — import the runtime classes from the explicit // `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. diff --git a/packages/core/src/types/specTypeSchema.examples.ts b/packages/core/src/types/specTypeSchema.examples.ts index 2fcd531ec0..0a9fd9727f 100644 --- a/packages/core/src/types/specTypeSchema.examples.ts +++ b/packages/core/src/types/specTypeSchema.examples.ts @@ -7,7 +7,7 @@ * @module */ -import { isSpecType, parseSpecType, safeParseSpecType, specTypeSchemas } from './specTypeSchema.js'; +import { isSpecType, specTypeSchemas } from './specTypeSchema.js'; declare const untrusted: unknown; declare const value: unknown; @@ -15,14 +15,38 @@ declare const mixed: unknown[]; function specTypeSchemas_basicUsage() { //#region specTypeSchemas_basicUsage - const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); - if (result.issues === undefined) { - // result.value is CallToolResult + const result = specTypeSchemas.CallToolResult.parse(untrusted); + // result is CallToolResult; throws SpecTypeValidationError on invalid input + + // Entries are Standard Schemas, so the underlying validator is also available: + const validated = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); + if (validated.issues === undefined) { + // validated.value is CallToolResult } //#endregion specTypeSchemas_basicUsage void result; } +function specTypeSchemas_parse() { + //#region specTypeSchemas_parse + const result = specTypeSchemas.CallToolResult.parse(untrusted); + // result is CallToolResult; throws SpecTypeValidationError on invalid input + //#endregion specTypeSchemas_parse + void result; +} + +function specTypeSchemas_safeParse() { + //#region specTypeSchemas_safeParse + const parsed = specTypeSchemas.Tool.safeParse(untrusted); + if (parsed.success) { + // parsed.data is Tool + } else { + // parsed.issues describes the failures + } + //#endregion specTypeSchemas_safeParse + void parsed; +} + function isSpecType_basicUsage() { /* eslint-disable unicorn/no-array-callback-reference -- showcasing the guard-as-callback pattern */ //#region isSpecType_basicUsage @@ -36,27 +60,7 @@ function isSpecType_basicUsage() { void blocks; } -function parseSpecType_basicUsage() { - //#region parseSpecType_basicUsage - const result = parseSpecType('CallToolResult', untrusted); - // result is CallToolResult; throws SpecTypeValidationError on invalid input - //#endregion parseSpecType_basicUsage - void result; -} - -function safeParseSpecType_basicUsage() { - //#region safeParseSpecType_basicUsage - const parsed = safeParseSpecType('Tool', untrusted); - if (parsed.success) { - // parsed.data is Tool - } else { - // parsed.issues describes the failures - } - //#endregion safeParseSpecType_basicUsage - void parsed; -} - void specTypeSchemas_basicUsage; +void specTypeSchemas_parse; +void specTypeSchemas_safeParse; void isSpecType_basicUsage; -void parseSpecType_basicUsage; -void safeParseSpecType_basicUsage; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 488e92e5aa..96a1750e00 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -235,14 +235,110 @@ type SpecTypeInputs = { [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never; }; -type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; +/** + * Result of {@linkcode SpecTypeSchema.safeParse}: a discriminated union shaped like the result of + * v1's `Schema.safeParse(value)`, so migrated call sites keep their `.success` / + * `.data` control flow. + */ +export type SafeParseSpecTypeResult = + | { readonly success: true; readonly data: T } + | { readonly success: false; readonly issues: ReadonlyArray }; + +function formatIssuePath(path: NonNullable): string { + return path + .map(segment => (typeof segment === 'object' && segment !== null && 'key' in segment ? String(segment.key) : String(segment))) + .join('.'); +} + +function formatIssues(issues: ReadonlyArray): string { + return issues.map(issue => (issue.path?.length ? `${formatIssuePath(issue.path)}: ${issue.message}` : issue.message)).join('; '); +} + +/** + * Error thrown by {@linkcode SpecTypeSchema.parse} when a value fails validation. + * + * Mirrors the shape v1 consumers relied on when catching `ZodError` from + * `Schema.parse()`: the failure details are available on + * {@linkcode SpecTypeValidationError.issues} and summarized in the message. + */ +export class SpecTypeValidationError extends Error { + readonly specType: SpecTypeName; + readonly issues: ReadonlyArray; + + constructor(specType: SpecTypeName, issues: ReadonlyArray) { + super(`Invalid ${specType}: ${formatIssues(issues)}`); + this.name = 'SpecTypeValidationError'; + this.specType = specType; + this.issues = issues; + } +} + +/** + * A {@linkcode specTypeSchemas} entry: a synchronous Standard Schema for one spec type, extended + * with `parse`/`safeParse` methods shaped like the Zod schemas v1 exported. + */ +export interface SpecTypeSchema extends StandardSchemaV1Sync { + /** + * Validates `value` and returns the parsed output, throwing + * {@linkcode SpecTypeValidationError} on failure. + * + * This is the direct replacement for v1's `Schema.parse(value)`. Validation is + * synchronous, so no `await` is needed. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#specTypeSchemas_parse" + * const result = specTypeSchemas.CallToolResult.parse(untrusted); + * // result is CallToolResult; throws SpecTypeValidationError on invalid input + * ``` + */ + parse(value: unknown): Output; + + /** + * Validates `value` without throwing. + * + * This is the direct replacement for v1's `Schema.safeParse(value)`. Validation is + * synchronous, so no `await` is needed. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#specTypeSchemas_safeParse" + * const parsed = specTypeSchemas.Tool.safeParse(untrusted); + * if (parsed.success) { + * // parsed.data is Tool + * } else { + * // parsed.issues describes the failures + * } + * ``` + */ + safeParse(value: unknown): SafeParseSpecTypeResult; +} + +type SchemaRecord = { readonly [K in SpecTypeName]: SpecTypeSchema }; type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; -const _specTypeSchemas: Record = {}; +const _specTypeSchemas: Record> = {}; const _isSpecType: Record boolean> = {}; function register(key: string, schema: z.ZodType): void { - const name = key.slice(0, -'Schema'.length); - _specTypeSchemas[name] = schema; + const name = key.slice(0, -'Schema'.length) as SpecTypeName; + // The backing protocol schemas validate synchronously; `~standard` itself is initialized + // lazily by the schema library, so it is only dereferenced on first use. + const standard = (): StandardSchemaV1Sync.Props => + (schema as unknown as StandardSchemaV1Sync)['~standard']; + _specTypeSchemas[name] = Object.freeze({ + get '~standard'(): StandardSchemaV1Sync.Props { + return standard(); + }, + parse(value: unknown): unknown { + const result = standard().validate(value); + if (result.issues) { + throw new SpecTypeValidationError(name, result.issues); + } + return result.value; + }, + safeParse(value: unknown): SafeParseSpecTypeResult { + const result = standard().validate(value); + return result.issues ? { success: false, issues: result.issues } : { success: true, data: result.value }; + } + }); _isSpecType[name] = (v: unknown) => schema.safeParse(v).success; } for (const key of SPEC_SCHEMA_KEYS) { @@ -261,17 +357,24 @@ for (const [key, schema] of Object.entries(authSchemas)) { * storage that should be a `Tool`. * * Each entry implements the Standard Schema interface, so it composes with any - * Standard-Schema-aware library. For a simple boolean check, use {@linkcode isSpecType} instead. + * Standard-Schema-aware library, and additionally offers {@linkcode SpecTypeSchema.parse} and + * {@linkcode SpecTypeSchema.safeParse} methods shaped like the Zod schemas v1 exported — v1's + * `CallToolResultSchema.parse(value)` becomes `specTypeSchemas.CallToolResult.parse(value)`. For a + * simple boolean check, use {@linkcode isSpecType} instead. * * @example * ```ts source="./specTypeSchema.examples.ts#specTypeSchemas_basicUsage" - * const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); - * if (result.issues === undefined) { - * // result.value is CallToolResult + * const result = specTypeSchemas.CallToolResult.parse(untrusted); + * // result is CallToolResult; throws SpecTypeValidationError on invalid input + * + * // Entries are Standard Schemas, so the underlying validator is also available: + * const validated = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); + * if (validated.issues === undefined) { + * // validated.value is CallToolResult * } * ``` */ -export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as SchemaRecord); +export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as unknown as SchemaRecord); /** * Type predicates for every MCP spec type, keyed by type name. @@ -280,7 +383,8 @@ export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as S * transforms are applied), and narrows to that input type. For schemas with `.default()` or * `.preprocess()`, this may accept values that do not structurally match the named output type; * for example `isSpecType.CallToolResult({})` is `true` because `content` has a default. Use - * `specTypeSchemas.X['~standard'].validate(value)` when you need the validated output value. + * `specTypeSchemas.X.parse(value)` (or `['~standard'].validate`) when you need the validated + * output value. * * Each guard is a standalone function, so it can be passed directly as a callback. * @@ -294,83 +398,3 @@ export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as S * ``` */ export const isSpecType: GuardRecord = Object.freeze(_isSpecType as GuardRecord); - -function formatIssuePath(path: NonNullable): string { - return path - .map(segment => (typeof segment === 'object' && segment !== null && 'key' in segment ? String(segment.key) : String(segment))) - .join('.'); -} - -function formatIssues(issues: ReadonlyArray): string { - return issues.map(issue => (issue.path?.length ? `${formatIssuePath(issue.path)}: ${issue.message}` : issue.message)).join('; '); -} - -/** - * Error thrown by {@linkcode parseSpecType} when a value fails validation. - * - * Mirrors the shape v1 consumers relied on when catching `ZodError` from - * `Schema.parse()`: the failure details are available on - * {@linkcode SpecTypeValidationError.issues} and summarized in the message. - */ -export class SpecTypeValidationError extends Error { - readonly specType: SpecTypeName; - readonly issues: ReadonlyArray; - - constructor(specType: SpecTypeName, issues: ReadonlyArray) { - super(`Invalid ${specType}: ${formatIssues(issues)}`); - this.name = 'SpecTypeValidationError'; - this.specType = specType; - this.issues = issues; - } -} - -/** - * Validates that `value` is a valid instance of the named spec type and returns the parsed value, - * throwing {@linkcode SpecTypeValidationError} on failure. - * - * This is the direct replacement for v1's `Schema.parse(value)`. Validation is - * synchronous (the backing schemas are {@linkcode StandardSchemaV1Sync}), so no `await` is needed. - * - * @example - * ```ts source="./specTypeSchema.examples.ts#parseSpecType_basicUsage" - * const result = parseSpecType('CallToolResult', untrusted); - * // result is CallToolResult; throws SpecTypeValidationError on invalid input - * ``` - */ -export function parseSpecType(name: K, value: unknown): SpecTypes[K] { - const result = specTypeSchemas[name]['~standard'].validate(value); - if (result.issues) { - throw new SpecTypeValidationError(name, result.issues); - } - return result.value; -} - -/** - * Result of {@linkcode safeParseSpecType}: a discriminated union shaped like the result of v1's - * `Schema.safeParse(value)`, so migrated call sites keep their `.success` / `.data` - * control flow. - */ -export type SafeParseSpecTypeResult = - | { readonly success: true; readonly data: T } - | { readonly success: false; readonly issues: ReadonlyArray }; - -/** - * Validates that `value` is a valid instance of the named spec type without throwing. - * - * This is the direct replacement for v1's `Schema.safeParse(value)`. Validation is - * synchronous (the backing schemas are {@linkcode StandardSchemaV1Sync}), so no `await` is needed. - * - * @example - * ```ts source="./specTypeSchema.examples.ts#safeParseSpecType_basicUsage" - * const parsed = safeParseSpecType('Tool', untrusted); - * if (parsed.success) { - * // parsed.data is Tool - * } else { - * // parsed.issues describes the failures - * } - * ``` - */ -export function safeParseSpecType(name: K, value: unknown): SafeParseSpecTypeResult { - const result = specTypeSchemas[name]['~standard'].validate(value); - return result.issues ? { success: false, issues: result.issues } : { success: true, data: result.value }; -} diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 44053dc819..4a6732d0dc 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -3,7 +3,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js'; import * as schemas from '../../src/types/schemas.js'; import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js'; -import { isSpecType, parseSpecType, safeParseSpecType, SpecTypeValidationError, specTypeSchemas } from '../../src/types/specTypeSchema.js'; +import { isSpecType, SpecTypeValidationError, specTypeSchemas } from '../../src/types/specTypeSchema.js'; import type { CallToolResult, ContentBlock, @@ -175,22 +175,22 @@ describe('SPEC_SCHEMA_KEYS allowlist', () => { }); }); -describe('parseSpecType', () => { +describe('specTypeSchemas.X.parse', () => { it('returns the parsed value for valid input', () => { - const value = parseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + const value = specTypeSchemas.Implementation.parse({ name: 'x', version: '1.0.0' }); expect(value).toEqual({ name: 'x', version: '1.0.0' }); expectTypeOf(value).toEqualTypeOf(); }); it('applies schema defaults to the returned value', () => { - const value = parseSpecType('CallToolResult', {}); + const value = specTypeSchemas.CallToolResult.parse({}); expect(value.content).toEqual([]); }); it('throws SpecTypeValidationError with issue summaries on invalid input', () => { - expect(() => parseSpecType('Implementation', { name: 'x' })).toThrowError(SpecTypeValidationError); + expect(() => specTypeSchemas.Implementation.parse({ name: 'x' })).toThrowError(SpecTypeValidationError); try { - parseSpecType('Implementation', { name: 'x' }); + specTypeSchemas.Implementation.parse({ name: 'x' }); expect.unreachable(); } catch (error) { expect(error).toBeInstanceOf(SpecTypeValidationError); @@ -203,26 +203,43 @@ describe('parseSpecType', () => { } }); + it('throws the SDK error class, not the schema library error', () => { + // The entries are SDK-owned wrappers; consumers must never see the internal + // validation library's error type cross the public boundary. + try { + specTypeSchemas.Implementation.parse({ name: 'x' }); + expect.unreachable(); + } catch (error) { + expect((error as Error).name).toBe('SpecTypeValidationError'); + expect((error as Error).constructor).toBe(SpecTypeValidationError); + } + }); + it('is synchronous — never returns a Promise', () => { - const value: Implementation = parseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + const value: Implementation = specTypeSchemas.Implementation.parse({ name: 'x', version: '1.0.0' }); expect(value).not.toBeInstanceOf(Promise); }); it('covers OAuth types', () => { - const tokens = parseSpecType('OAuthTokens', { access_token: 'x', token_type: 'Bearer' }); + const tokens = specTypeSchemas.OAuthTokens.parse({ access_token: 'x', token_type: 'Bearer' }); expectTypeOf(tokens).toEqualTypeOf(); expect(tokens.access_token).toBe('x'); }); - it('rejects unknown spec type names at compile time', () => { - // @ts-expect-error - 'NotASpecType' is not a SpecTypeName - expect(() => parseSpecType('NotASpecType', {})).toThrowError(); + it("agrees with the entry's own Standard Schema validator", () => { + const input = { name: 'x', version: '1.0.0' }; + const viaParse = specTypeSchemas.Implementation.parse(input); + const viaValidate = specTypeSchemas.Implementation['~standard'].validate(input); + expect(viaValidate.issues).toBeUndefined(); + if (viaValidate.issues === undefined) { + expect(viaParse).toEqual(viaValidate.value); + } }); }); -describe('safeParseSpecType', () => { +describe('specTypeSchemas.X.safeParse', () => { it('returns { success: true, data } for valid input', () => { - const parsed = safeParseSpecType('Tool', { name: 't', inputSchema: { type: 'object' } }); + const parsed = specTypeSchemas.Tool.safeParse({ name: 't', inputSchema: { type: 'object' } }); expect(parsed.success).toBe(true); if (parsed.success) { expectTypeOf(parsed.data).toEqualTypeOf(); @@ -231,7 +248,7 @@ describe('safeParseSpecType', () => { }); it('returns { success: false, issues } for invalid input', () => { - const parsed = safeParseSpecType('Tool', { inputSchema: { type: 'object' } }); + const parsed = specTypeSchemas.Tool.safeParse({ inputSchema: { type: 'object' } }); expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.issues.length).toBeGreaterThan(0); @@ -239,8 +256,16 @@ describe('safeParseSpecType', () => { } }); + it('applies schema defaults, distinguishing parsed output from input', () => { + const parsed = specTypeSchemas.CallToolResult.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.content).toEqual([]); + } + }); + it('narrows the result type through the success discriminant', () => { - const parsed = safeParseSpecType('JSONRPCRequest', { jsonrpc: '2.0', id: 1, method: 'ping' }); + const parsed = specTypeSchemas.JSONRPCRequest.safeParse({ jsonrpc: '2.0', id: 1, method: 'ping' }); if (parsed.success) { expectTypeOf(parsed.data).toEqualTypeOf(); } else { @@ -250,8 +275,18 @@ describe('safeParseSpecType', () => { }); it('is synchronous — result is directly accessible without await', () => { - const parsed = safeParseSpecType('Implementation', { name: 'x', version: '1.0.0' }); + const parsed = specTypeSchemas.Implementation.safeParse({ name: 'x', version: '1.0.0' }); expect(parsed).not.toBeInstanceOf(Promise); expect(parsed.success).toBe(true); }); + + it('covers OAuth types', () => { + const parsed = specTypeSchemas.OAuthTokens.safeParse({ token_type: 'Bearer' }); + expect(parsed.success).toBe(false); + }); + + it('entries are frozen — methods cannot be reassigned', () => { + expect(Object.isFrozen(specTypeSchemas.CallToolResult)).toBe(true); + expect(Object.isFrozen(specTypeSchemas)).toBe(true); + }); }); diff --git a/packages/server/test/server/parseSpecTypeWire.test.ts b/packages/server/test/server/specTypeSchemaMethodsWire.test.ts similarity index 84% rename from packages/server/test/server/parseSpecTypeWire.test.ts rename to packages/server/test/server/specTypeSchemaMethodsWire.test.ts index caca1aff31..7e7c3bd6d2 100644 --- a/packages/server/test/server/parseSpecTypeWire.test.ts +++ b/packages/server/test/server/specTypeSchemaMethodsWire.test.ts @@ -1,20 +1,14 @@ import type { CallToolResult, JSONRPCMessage, ListToolsResult } from '@modelcontextprotocol/core'; -import { - InMemoryTransport, - LATEST_PROTOCOL_VERSION, - parseSpecType, - safeParseSpecType, - SpecTypeValidationError -} from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SpecTypeValidationError, specTypeSchemas } from '@modelcontextprotocol/core'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; import { McpServer } from '../../src/index.js'; -// Validates real wire payloads with parseSpecType/safeParseSpecType — the +// Validates real wire payloads with specTypeSchemas.X.parse/.safeParse — the // replacement for the v1 pattern of calling SomeResultSchema.parse() on a // response received from a live server, rather than on hand-built fixtures. -describe('parseSpecType on wire data', () => { +describe('specTypeSchemas parse methods on wire data', () => { async function roundTrip(): Promise<{ callToolResult: unknown; listToolsResult: unknown }> { const server = new McpServer({ name: 't', version: '1.0.0' }); server.registerTool( @@ -63,7 +57,7 @@ describe('parseSpecType on wire data', () => { it('parses a tools/call result received over a real transport', async () => { const { callToolResult } = await roundTrip(); - const parsed = parseSpecType('CallToolResult', callToolResult); + const parsed = specTypeSchemas.CallToolResult.parse(callToolResult); expectTypeOf(parsed).toEqualTypeOf(); expect(parsed.content).toEqual([{ type: 'text', text: '7' }]); }); @@ -71,7 +65,7 @@ describe('parseSpecType on wire data', () => { it('parses a tools/list result received over a real transport, preserving advertised schema', async () => { const { listToolsResult } = await roundTrip(); - const parsed = parseSpecType('ListToolsResult', listToolsResult); + const parsed = specTypeSchemas.ListToolsResult.parse(listToolsResult); expectTypeOf(parsed).toEqualTypeOf(); expect(parsed.tools).toHaveLength(1); expect(parsed.tools[0]?.name).toBe('add'); @@ -82,8 +76,8 @@ describe('parseSpecType on wire data', () => { it('rejects the same wire payload when validated as a different spec type', async () => { const { callToolResult } = await roundTrip(); - expect(() => parseSpecType('Implementation', callToolResult)).toThrowError(SpecTypeValidationError); - const parsed = safeParseSpecType('Implementation', callToolResult); + expect(() => specTypeSchemas.Implementation.parse(callToolResult)).toThrowError(SpecTypeValidationError); + const parsed = specTypeSchemas.Implementation.safeParse(callToolResult); expect(parsed.success).toBe(false); if (!parsed.success) { expect(parsed.issues.length).toBeGreaterThan(0); From b33071296a95d619d67be601b2e3a54262f45edf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 10:21:47 +0000 Subject: [PATCH 4/7] Make SpecTypeValidationError an SdkError subclass; bump published packages in changeset --- .changeset/spec-type-parse-helpers.md | 5 +- PR-DESCRIPTION.md | 69 +++++++++++++++++++ docs/migration-SKILL.md | 2 +- docs/migration.md | 2 +- packages/core/src/errors/sdkErrors.ts | 4 ++ packages/core/src/exports/public/index.ts | 1 + packages/core/src/types/specTypeSchema.ts | 31 +++++++-- .../core/test/types/specTypeSchema.test.ts | 25 +++++++ 8 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 PR-DESCRIPTION.md diff --git a/.changeset/spec-type-parse-helpers.md b/.changeset/spec-type-parse-helpers.md index 15437a5e8e..c7e28423da 100644 --- a/.changeset/spec-type-parse-helpers.md +++ b/.changeset/spec-type-parse-helpers.md @@ -1,5 +1,6 @@ --- -'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor --- -Add v1-style `parse`/`safeParse` methods to every `specTypeSchemas` entry, so v1 call sites migrate with a one-line rename: `CallToolResultSchema.parse(value)` becomes `specTypeSchemas.CallToolResult.parse(value)`. `parse` returns the parsed value or throws `SpecTypeValidationError` (with `.issues`); `safeParse` returns a `{ success, data | issues }` discriminated union so migrated call sites keep their control flow. Both are synchronous, and each entry remains a Standard Schema (`['~standard'].validate`). +Add v1-style `parse`/`safeParse` methods to every `specTypeSchemas` entry, so v1 call sites migrate with a one-line rename: `CallToolResultSchema.parse(value)` becomes `specTypeSchemas.CallToolResult.parse(value)`. `parse` returns the parsed value or throws `SpecTypeValidationError` (an `SdkError` subclass with code `SdkErrorCode.InvalidSpecType`, carrying `.specType` and `.issues`); `safeParse` returns a `{ success, data | issues }` discriminated union so migrated call sites keep their control flow. Both are synchronous, and each entry remains a Standard Schema (`['~standard'].validate`). diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md new file mode 100644 index 0000000000..20cd7e8a94 --- /dev/null +++ b/PR-DESCRIPTION.md @@ -0,0 +1,69 @@ +Add v1-style `.parse()` / `.safeParse()` methods to every `specTypeSchemas` entry, so v1 runtime-validation call sites migrate with a one-line rename. + +## Motivation and Context + +**Backwards-compatibility gap this fixes:** v1 exported the protocol Zod schemas, and runtime validation with `Schema.parse(value)` / `Schema.safeParse(value)` was a documented, widely-used pattern. v2 removed the schema exports, and the current migration path is: + +```ts +// v1 +const result = CallToolResultSchema.parse(value); // throws ZodError (.issues) on failure +const parsed = OAuthTokensSchema.safeParse(value); +if (parsed.success) use(parsed.data); + +// v2 today +const r = specTypeSchemas.CallToolResult['~standard'].validate(value); +if (r.issues !== undefined) throw new Error(/* hand-rolled from r.issues */); +use(r.value); +``` + +Every call site needs the same mechanical remap (`.success` → `.issues === undefined`, `.data` → `.value`, hand-rolled throw), and the result-shape inversion is easy to get wrong silently (e.g. `if (r.issues)` vs `if (!r.success)` confusion). While migrating a large production MCP host application from v1 to v2, this remap was needed at a dozen call sites (OAuth metadata/token validation, JSON-RPC message validation in custom transports, `_meta` payload checks) and several initially landed inverted or with defensive `await`s because the synchronous-validation guarantee is easy to miss. + +With this change, the `specTypeSchemas` entries themselves carry the familiar methods, so migration is a pure rename: + +```ts +// v1 +const result = CallToolResultSchema.parse(value); +const parsed = OAuthTokensSchema.safeParse(value); + +// v2 with this PR +const result = specTypeSchemas.CallToolResult.parse(value); +const parsed = specTypeSchemas.OAuthTokens.safeParse(value); +``` + +- `.parse(value)` returns the parsed value; on failure throws `SpecTypeValidationError` — an `SdkError` subclass with the new code `SdkErrorCode.InvalidSpecType`, so it composes with generic `instanceof SdkError` handling — whose message summarizes the failures and whose `.issues` carries the structured issues (playing the role `ZodError.issues` did in v1 catch blocks). +- `.safeParse(value)` returns `{ success: true, data } | { success: false, issues }`, so migrated call sites keep their `.success`/`.data` control flow unchanged. + +Both are synchronous (the backing schemas validate synchronously — no `await` needed), and each entry remains a Standard Schema: `['~standard'].validate` keeps working and stays the documented underlying mechanism. The entries are SDK-owned frozen wrappers, so the internal validation library's error types never cross the public boundary. + +## How Has This Been Tested? + +- Unit tests in `packages/core/test/types/specTypeSchema.test.ts`: valid/invalid inputs for both methods, schema-default application (proving the parsed output is returned, not the input), `SpecTypeValidationError` name/message/`.specType`/`.issues`, the error being an `SdkError` with `SdkErrorCode.InvalidSpecType` (caught by generic `instanceof SdkError` handlers), the thrown error being the SDK class (not the internal library's), OAuth-record coverage, agreement between `.parse` and the entry's own `['~standard'].validate`, type-level inference (`expectTypeOf`), discriminant narrowing, sync (non-Promise) results, frozen entries, and compile-time rejection of unknown names. +- Wire-level integration tests in `packages/server/test/server/specTypeSchemaMethodsWire.test.ts`: an `McpServer` with a registered tool is driven over a real `InMemoryTransport` (initialize → tools/list → tools/call), and the raw JSON-RPC `result` payloads taken off the wire are validated with `specTypeSchemas.X.parse`/`.safeParse` — the exact v1 `Schema.parse(response.result)` call-site pattern this replaces — including a negative case validating the same wire payload against a different spec type. +- Full package suites pass locally: core 566, client 367, server 75. Lint, typecheck, and `sync:snippets` clean. +- The equivalent call shape was used throughout a real v1→v2 migration of a large MCP host application (client + server + custom transports), which is where the ergonomics gap was identified. + +## Breaking Changes + +None — purely additive. The entries' declared type widens from `StandardSchemaV1Sync` to `SpecTypeSchema` (a `StandardSchemaV1Sync` with the two methods); existing `['~standard'].validate` call sites are unaffected. + +## Types of changes + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [x] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [x] Documentation update + +## Checklist + +- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) +- [x] My code follows the repository's style guidelines +- [x] New and existing tests pass locally +- [x] I have added appropriate error handling +- [x] I have added or updated documentation as needed + +## Additional context + +- The API was reshaped from an earlier revision of this branch (string-keyed top-level `parseSpecType(name, value)` helpers) to methods on the schema entries, based on review feedback: property access matches the `specTypeSchemas`/`isSpecType` family and avoids name-string arguments entirely. +- Both migration guides now show the one-line rename as the primary replacement for v1 `parse`/`safeParse` call sites, with `['~standard'].validate` kept documented as the underlying mechanism, and state explicitly that validation is synchronous. +- Follow-up opportunity: the codemod's spec-schema transform can emit `.parse`/`.safeParse` directly once this lands, instead of rewriting call sites to the `['~standard'].validate` remap. +- `SpecTypeValidationError` exposes `.specType` and `.issues` so catch blocks that previously inspected `ZodError.issues` have a structured equivalent. Per review feedback it extends `SdkError` (mirroring `SdkHttpError`) rather than adding a new error root, and carries its extras in `.data` typed as `SpecTypeValidationErrorData`. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 220ff30513..f7e992af99 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -475,7 +475,7 @@ If a `*Schema` constant was used for **runtime validation** (not just as a `requ | v1 pattern | v2 replacement | | -------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `Schema.parse(value)` | `specTypeSchemas..parse(value)` (returns the parsed value; throws `SpecTypeValidationError` with `.issues`) | +| `Schema.parse(value)` | `specTypeSchemas..parse(value)` (returns the parsed value; throws `SpecTypeValidationError`, an `SdkError` subclass with code `SdkErrorCode.InvalidSpecType`, with `.issues`) | | `Schema.safeParse(value)` | `specTypeSchemas..safeParse(value)` (`{ success: true, data }` \| `{ success: false, issues }`) | | `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | | `Schema.safeParse(value).success` | `isSpecType.(value)` | diff --git a/docs/migration.md b/docs/migration.md index 6ceb8c3ab5..a3a14594cc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -530,7 +530,7 @@ const blocks = mixed.filter(isSpecType.ContentBlock); const r = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` -All of these are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. Validation is **synchronous** throughout: the backing schemas are `StandardSchemaV1Sync`, so `parse`/`safeParse`/`validate()` return results directly and no `await` is needed. On failure, `parse` throws `SpecTypeValidationError`, whose `.issues` plays the role v1's `ZodError.issues` did in catch blocks. The pre-existing `isCallToolResult(value)` guard still works. +All of these are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. Validation is **synchronous** throughout: the backing schemas are `StandardSchemaV1Sync`, so `parse`/`safeParse`/`validate()` return results directly and no `await` is needed. On failure, `parse` throws `SpecTypeValidationError` — an `SdkError` subclass with code `SdkErrorCode.InvalidSpecType`, so generic `instanceof SdkError` handlers catch it — whose `.issues` plays the role v1's `ZodError.issues` did in catch blocks. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts index af432c6389..998295b4c8 100644 --- a/packages/core/src/errors/sdkErrors.ts +++ b/packages/core/src/errors/sdkErrors.ts @@ -29,6 +29,10 @@ export enum SdkErrorCode { /** Response result failed local schema validation */ InvalidResult = 'INVALID_RESULT', + // Validation errors + /** Value failed spec type schema validation via `parse`/`safeParse` */ + InvalidSpecType = 'INVALID_SPEC_TYPE', + // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION', diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 37236782f1..17be9fafc4 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -107,6 +107,7 @@ export { // Validator types and classes export type { SafeParseSpecTypeResult, SpecTypeName, SpecTypes, SpecTypeSchema } from '../../types/specTypeSchema.js'; +export type { SpecTypeValidationErrorData } from '../../types/specTypeSchema.js'; export { isSpecType, specTypeSchemas, SpecTypeValidationError } from '../../types/specTypeSchema.js'; export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema.js'; // Validator providers are type-only here — import the runtime classes from the explicit diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 96a1750e00..551a48fdef 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -1,5 +1,6 @@ import type * as z from 'zod/v4'; +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import { OAuthClientInformationFullSchema, OAuthClientInformationSchema, @@ -254,22 +255,38 @@ function formatIssues(issues: ReadonlyArray): string { return issues.map(issue => (issue.path?.length ? `${formatIssuePath(issue.path)}: ${issue.message}` : issue.message)).join('; '); } +/** + * Typed shape for validation failure data carried by {@linkcode SpecTypeValidationError}. + */ +export interface SpecTypeValidationErrorData { + specType: SpecTypeName; + issues: ReadonlyArray; + [key: string]: unknown; +} + /** * Error thrown by {@linkcode SpecTypeSchema.parse} when a value fails validation. * - * Mirrors the shape v1 consumers relied on when catching `ZodError` from + * An {@linkcode SdkError} subclass with {@linkcode SdkErrorCode.InvalidSpecType}, so generic + * `instanceof SdkError` handlers and `error.code` switches catch it alongside the SDK's other + * local errors. Mirrors the shape v1 consumers relied on when catching `ZodError` from * `Schema.parse()`: the failure details are available on * {@linkcode SpecTypeValidationError.issues} and summarized in the message. */ -export class SpecTypeValidationError extends Error { - readonly specType: SpecTypeName; - readonly issues: ReadonlyArray; +export class SpecTypeValidationError extends SdkError { + declare readonly data: SpecTypeValidationErrorData; constructor(specType: SpecTypeName, issues: ReadonlyArray) { - super(`Invalid ${specType}: ${formatIssues(issues)}`); + super(SdkErrorCode.InvalidSpecType, `Invalid ${specType}: ${formatIssues(issues)}`, { specType, issues }); this.name = 'SpecTypeValidationError'; - this.specType = specType; - this.issues = issues; + } + + get specType(): SpecTypeName { + return this.data.specType; + } + + get issues(): ReadonlyArray { + return this.data.issues; } } diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 4a6732d0dc..a81f72782a 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js'; import * as schemas from '../../src/types/schemas.js'; import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js'; +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; import { isSpecType, SpecTypeValidationError, specTypeSchemas } from '../../src/types/specTypeSchema.js'; import type { CallToolResult, @@ -203,6 +204,30 @@ describe('specTypeSchemas.X.parse', () => { } }); + it('is an SdkError with code InvalidSpecType', () => { + try { + specTypeSchemas.Implementation.parse({ name: 'x' }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.InvalidSpecType); + } + }); + + it('is caught by generic SdkError handling', () => { + // The composability the consolidated error taxonomy exists for: a handler written + // against SdkError + code discrimination sees parse failures without a dedicated branch. + let handled: string | undefined; + try { + specTypeSchemas.Implementation.parse({ name: 'x' }); + } catch (error) { + if (error instanceof SdkError) { + handled = error.code; + } + } + expect(handled).toBe(SdkErrorCode.InvalidSpecType); + }); + it('throws the SDK error class, not the schema library error', () => { // The entries are SDK-owned wrappers; consumers must never see the internal // validation library's error type cross the public boundary. From 5b68b059fbe4ed43c60689365d0ce34b17a36e3b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 16:43:48 +0000 Subject: [PATCH 5/7] Remove accidentally committed authoring artifact --- PR-DESCRIPTION.md | 69 ----------------------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 PR-DESCRIPTION.md diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md deleted file mode 100644 index 20cd7e8a94..0000000000 --- a/PR-DESCRIPTION.md +++ /dev/null @@ -1,69 +0,0 @@ -Add v1-style `.parse()` / `.safeParse()` methods to every `specTypeSchemas` entry, so v1 runtime-validation call sites migrate with a one-line rename. - -## Motivation and Context - -**Backwards-compatibility gap this fixes:** v1 exported the protocol Zod schemas, and runtime validation with `Schema.parse(value)` / `Schema.safeParse(value)` was a documented, widely-used pattern. v2 removed the schema exports, and the current migration path is: - -```ts -// v1 -const result = CallToolResultSchema.parse(value); // throws ZodError (.issues) on failure -const parsed = OAuthTokensSchema.safeParse(value); -if (parsed.success) use(parsed.data); - -// v2 today -const r = specTypeSchemas.CallToolResult['~standard'].validate(value); -if (r.issues !== undefined) throw new Error(/* hand-rolled from r.issues */); -use(r.value); -``` - -Every call site needs the same mechanical remap (`.success` → `.issues === undefined`, `.data` → `.value`, hand-rolled throw), and the result-shape inversion is easy to get wrong silently (e.g. `if (r.issues)` vs `if (!r.success)` confusion). While migrating a large production MCP host application from v1 to v2, this remap was needed at a dozen call sites (OAuth metadata/token validation, JSON-RPC message validation in custom transports, `_meta` payload checks) and several initially landed inverted or with defensive `await`s because the synchronous-validation guarantee is easy to miss. - -With this change, the `specTypeSchemas` entries themselves carry the familiar methods, so migration is a pure rename: - -```ts -// v1 -const result = CallToolResultSchema.parse(value); -const parsed = OAuthTokensSchema.safeParse(value); - -// v2 with this PR -const result = specTypeSchemas.CallToolResult.parse(value); -const parsed = specTypeSchemas.OAuthTokens.safeParse(value); -``` - -- `.parse(value)` returns the parsed value; on failure throws `SpecTypeValidationError` — an `SdkError` subclass with the new code `SdkErrorCode.InvalidSpecType`, so it composes with generic `instanceof SdkError` handling — whose message summarizes the failures and whose `.issues` carries the structured issues (playing the role `ZodError.issues` did in v1 catch blocks). -- `.safeParse(value)` returns `{ success: true, data } | { success: false, issues }`, so migrated call sites keep their `.success`/`.data` control flow unchanged. - -Both are synchronous (the backing schemas validate synchronously — no `await` needed), and each entry remains a Standard Schema: `['~standard'].validate` keeps working and stays the documented underlying mechanism. The entries are SDK-owned frozen wrappers, so the internal validation library's error types never cross the public boundary. - -## How Has This Been Tested? - -- Unit tests in `packages/core/test/types/specTypeSchema.test.ts`: valid/invalid inputs for both methods, schema-default application (proving the parsed output is returned, not the input), `SpecTypeValidationError` name/message/`.specType`/`.issues`, the error being an `SdkError` with `SdkErrorCode.InvalidSpecType` (caught by generic `instanceof SdkError` handlers), the thrown error being the SDK class (not the internal library's), OAuth-record coverage, agreement between `.parse` and the entry's own `['~standard'].validate`, type-level inference (`expectTypeOf`), discriminant narrowing, sync (non-Promise) results, frozen entries, and compile-time rejection of unknown names. -- Wire-level integration tests in `packages/server/test/server/specTypeSchemaMethodsWire.test.ts`: an `McpServer` with a registered tool is driven over a real `InMemoryTransport` (initialize → tools/list → tools/call), and the raw JSON-RPC `result` payloads taken off the wire are validated with `specTypeSchemas.X.parse`/`.safeParse` — the exact v1 `Schema.parse(response.result)` call-site pattern this replaces — including a negative case validating the same wire payload against a different spec type. -- Full package suites pass locally: core 566, client 367, server 75. Lint, typecheck, and `sync:snippets` clean. -- The equivalent call shape was used throughout a real v1→v2 migration of a large MCP host application (client + server + custom transports), which is where the ergonomics gap was identified. - -## Breaking Changes - -None — purely additive. The entries' declared type widens from `StandardSchemaV1Sync` to `SpecTypeSchema` (a `StandardSchemaV1Sync` with the two methods); existing `['~standard'].validate` call sites are unaffected. - -## Types of changes - -- [ ] Bug fix (non-breaking change which fixes an issue) -- [x] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [x] Documentation update - -## Checklist - -- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) -- [x] My code follows the repository's style guidelines -- [x] New and existing tests pass locally -- [x] I have added appropriate error handling -- [x] I have added or updated documentation as needed - -## Additional context - -- The API was reshaped from an earlier revision of this branch (string-keyed top-level `parseSpecType(name, value)` helpers) to methods on the schema entries, based on review feedback: property access matches the `specTypeSchemas`/`isSpecType` family and avoids name-string arguments entirely. -- Both migration guides now show the one-line rename as the primary replacement for v1 `parse`/`safeParse` call sites, with `['~standard'].validate` kept documented as the underlying mechanism, and state explicitly that validation is synchronous. -- Follow-up opportunity: the codemod's spec-schema transform can emit `.parse`/`.safeParse` directly once this lands, instead of rewriting call sites to the `['~standard'].validate` remap. -- `SpecTypeValidationError` exposes `.specType` and `.issues` so catch blocks that previously inspected `ZodError.issues` have a structured equivalent. Per review feedback it extends `SdkError` (mirroring `SdkHttpError`) rather than adding a new error root, and carries its extras in `.data` typed as `SpecTypeValidationErrorData`. From 76561bdcced2375575e8d246c9dfce1eea375e85 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 16:43:48 +0000 Subject: [PATCH 6/7] Document SdkErrorCode.InvalidSpecType and the codemod's current schema-access output --- docs/migration-SKILL.md | 3 +++ docs/migration.md | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f7e992af99..70f9ae8174 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -134,6 +134,7 @@ New `SdkErrorCode` enum values: - `SdkErrorCode.ConnectionClosed` = `'CONNECTION_CLOSED'` - `SdkErrorCode.SendFailed` = `'SEND_FAILED'` - `SdkErrorCode.InvalidResult` = `'INVALID_RESULT'` +- `SdkErrorCode.InvalidSpecType` = `'INVALID_SPEC_TYPE'` - `SdkErrorCode.ClientHttpNotImplemented` = `'CLIENT_HTTP_NOT_IMPLEMENTED'` - `SdkErrorCode.ClientHttpAuthentication` = `'CLIENT_HTTP_AUTHENTICATION'` - `SdkErrorCode.ClientHttpForbidden` = `'CLIENT_HTTP_FORBIDDEN'` @@ -483,6 +484,8 @@ If a `*Schema` constant was used for **runtime validation** (not just as a `requ All validation is synchronous (`StandardSchemaV1Sync`) — never add `await`. `isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. +Note: the v1→v2 codemod currently rewrites these call sites to the lower-level `specTypeSchemas.['~standard'].validate(value)` form. That form is equivalent and stays supported, but `parse`/`safeParse` is the recommended form when writing or revising call sites by hand; a codemod update to emit the methods directly is planned. + ## 12. Experimental tasks interception removed The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages. diff --git a/docs/migration.md b/docs/migration.md index a3a14594cc..c9a19a64b1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -734,6 +734,7 @@ The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: | `SdkErrorCode.ConnectionClosed` | Connection was closed | | `SdkErrorCode.SendFailed` | Failed to send message | | `SdkErrorCode.InvalidResult` | Response result failed local schema validation | +| `SdkErrorCode.InvalidSpecType` | Value failed spec type schema validation via `parse`/`safeParse` | | `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | | `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | | `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | From 81335d9deeac798dfaece11718522572beb752a6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 17:45:08 +0000 Subject: [PATCH 7/7] Correct codemod diagnostics that claim parse/safeParse are unavailable --- .../v1-to-v2/transforms/specSchemaAccess.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts index 015c706abc..e141b65312 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts @@ -105,8 +105,8 @@ function handleReference( actionRequired( sourceFile.getFilePath(), ref, - `${localName}.safeParse() not available in v2. Use \`isSpecType.${typeName}(value)\` for boolean validation, ` + - `or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` for full result.` + `${localName}.safeParse() was removed with the v1 schema exports. Rewrite as \`specTypeSchemas.${typeName}.safeParse(value)\` ` + + `(returns { success, data | issues }), or use \`isSpecType.${typeName}(value)\` for a boolean check.` ) ); return false; @@ -118,8 +118,8 @@ function handleReference( actionRequired( sourceFile.getFilePath(), ref, - `${localName}.parse() not available in v2. Use \`isSpecType.${typeName}(value)\` for validation, ` + - `or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` and check for issues.` + `${localName}.parse() was removed with the v1 schema exports. Rewrite as \`specTypeSchemas.${typeName}.parse(value)\` ` + + `(throws SpecTypeValidationError on failure), or use \`isSpecType.${typeName}(value)\` for a boolean check.` ) ); return false; @@ -135,7 +135,7 @@ function handleReference( warning( sourceFile.getFilePath(), line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.` + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — .parse()/.safeParse() are provided, but Zod-specific APIs like .parseAsync() are not. Verify the accessed member.` ) ); return true; @@ -160,7 +160,7 @@ function handleReference( warning( sourceFile.getFilePath(), line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — .parse()/.safeParse() are provided, but Zod-specific APIs (e.g. .parseAsync(), .extend()) are not.` ) ); return true; @@ -178,7 +178,7 @@ function handleReference( warning( sourceFile.getFilePath(), line, - `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — .parse()/.safeParse() are provided, but Zod-specific APIs (e.g. .parseAsync(), .extend()) are not.` ) ); return true;