diff --git a/.changeset/spec-type-parse-helpers.md b/.changeset/spec-type-parse-helpers.md new file mode 100644 index 0000000000..c7e28423da --- /dev/null +++ b/.changeset/spec-type-parse-helpers.md @@ -0,0 +1,6 @@ +--- +'@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` (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/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..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'` @@ -471,16 +472,19 @@ 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), the `specTypeSchemas.` entries carry `parse`/`safeParse` methods — a mechanical rename: -| 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)` | `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)` | +| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync` with `parse`/`safeParse`) | -`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. + +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 diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..c9a19a64b1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -506,29 +506,31 @@ 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), 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 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: keyed type predicate +// 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'; if (isSpecType.CallToolResult(value)) { /* ... */ } const blocks = mixed.filter(isSpecType.ContentBlock); -// v2: or get the StandardSchemaV1Sync validator object directly -import { specTypeSchemas } from '@modelcontextprotocol/client'; -const result = specTypeSchemas.CallToolResult['~standard'].validate(value); +// v2: each entry is also a Standard Schema, for Standard-Schema-aware libraries +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 `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 @@ -732,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 | 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; 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 729144f1a3..17be9fafc4 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -106,8 +106,9 @@ 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, 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 // `@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..0a9fd9727f 100644 --- a/packages/core/src/types/specTypeSchema.examples.ts +++ b/packages/core/src/types/specTypeSchema.examples.ts @@ -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 @@ -37,4 +61,6 @@ function isSpecType_basicUsage() { } void specTypeSchemas_basicUsage; +void specTypeSchemas_parse; +void specTypeSchemas_safeParse; void isSpecType_basicUsage; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 477d61a55a..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, @@ -235,14 +236,126 @@ 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('; '); +} + +/** + * 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. + * + * 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 SdkError { + declare readonly data: SpecTypeValidationErrorData; + + constructor(specType: SpecTypeName, issues: ReadonlyArray) { + super(SdkErrorCode.InvalidSpecType, `Invalid ${specType}: ${formatIssues(issues)}`, { specType, issues }); + this.name = 'SpecTypeValidationError'; + } + + get specType(): SpecTypeName { + return this.data.specType; + } + + get issues(): ReadonlyArray { + return this.data.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 +374,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 +400,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. * diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..a81f72782a 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -3,7 +3,8 @@ 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 { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { isSpecType, SpecTypeValidationError, specTypeSchemas } from '../../src/types/specTypeSchema.js'; import type { CallToolResult, ContentBlock, @@ -174,3 +175,143 @@ describe('SPEC_SCHEMA_KEYS allowlist', () => { expect(actual).toEqual(expected); }); }); + +describe('specTypeSchemas.X.parse', () => { + it('returns the parsed value for valid input', () => { + 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 = specTypeSchemas.CallToolResult.parse({}); + expect(value.content).toEqual([]); + }); + + it('throws SpecTypeValidationError with issue summaries on invalid input', () => { + expect(() => specTypeSchemas.Implementation.parse({ name: 'x' })).toThrowError(SpecTypeValidationError); + try { + specTypeSchemas.Implementation.parse({ 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 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. + 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 = specTypeSchemas.Implementation.parse({ name: 'x', version: '1.0.0' }); + expect(value).not.toBeInstanceOf(Promise); + }); + + it('covers OAuth types', () => { + const tokens = specTypeSchemas.OAuthTokens.parse({ access_token: 'x', token_type: 'Bearer' }); + expectTypeOf(tokens).toEqualTypeOf(); + expect(tokens.access_token).toBe('x'); + }); + + 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('specTypeSchemas.X.safeParse', () => { + it('returns { success: true, data } for valid input', () => { + const parsed = specTypeSchemas.Tool.safeParse({ 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 = specTypeSchemas.Tool.safeParse({ inputSchema: { type: 'object' } }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.issues.length).toBeGreaterThan(0); + expect(parsed.issues[0]?.message).toBeTruthy(); + } + }); + + 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 = specTypeSchemas.JSONRPCRequest.safeParse({ 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 = 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/specTypeSchemaMethodsWire.test.ts b/packages/server/test/server/specTypeSchemaMethodsWire.test.ts new file mode 100644 index 0000000000..7e7c3bd6d2 --- /dev/null +++ b/packages/server/test/server/specTypeSchemaMethodsWire.test.ts @@ -0,0 +1,86 @@ +import type { CallToolResult, JSONRPCMessage, ListToolsResult } 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 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('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( + '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 = specTypeSchemas.CallToolResult.parse(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 = specTypeSchemas.ListToolsResult.parse(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(() => 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); + } + }); +});