diff --git a/.changeset/spec-type-schema.md b/.changeset/spec-type-schema.md new file mode 100644 index 000000000..d2a051d53 --- /dev/null +++ b/.changeset/spec-type-schema.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Export `specTypeSchema(name)` and `isSpecType(name, value)` for runtime validation of any MCP spec type by name. `specTypeSchema` returns a `StandardSchemaV1` validator; `isSpecType` is a boolean type predicate. Also export the `StandardSchemaV1`, `SpecTypeName`, and `SpecTypes` types. diff --git a/CLAUDE.md b/CLAUDE.md index 609c920cb..86e5bea00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Include what changed, why, and how to migrate. Search for related sections and g - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix - **Imports**: ES module style, include `.js` extension, group imports logically - **Formatting**: 2-space indentation, semicolons required, single quotes preferred -- **Testing**: Co-locate tests with source files, use descriptive test names +- **Testing**: Place tests under each package's `test/` directory (vitest only includes `test/**/*.test.ts`), use descriptive test names - **Comments**: JSDoc for public APIs, inline comments for complex logic ### JSDoc `@example` Code Snippets diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index a37b5e206..0b28bcf03 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -36,13 +36,13 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Client imports -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------ | -| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | -| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client` | | `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | ### Server imports @@ -59,8 +59,8 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Types / shared imports -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------- | +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------------------------------------------- | | `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | @@ -81,24 +81,25 @@ Notes: ## 5. Removed / Renamed Type Aliases and Symbols -| v1 (removed) | v2 (replacement) | -| ---------------------------------------- | -------------------------------------------------------- | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| v1 (removed) | v2 (replacement) | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | | `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` (**not** v2's new `isJSONRPCResponse`, which correctly matches both result and error) | -| `ResourceReference` | `ResourceTemplateReference` | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | -| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | -| `McpError` | `ProtocolError` | -| `ErrorCode` | `ProtocolErrorCode` | -| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | -| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | -| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | -| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | - -All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use type guard functions like `isCallToolResult` instead of `CallToolResultSchema.safeParse()`. +| `ResourceReference` | `ResourceTemplateReference` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | +| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | +| `StreamableHTTPError` | REMOVED (use `SdkError` with `SdkErrorCode.ClientHttp*`) | +| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | + +All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use +`isSpecType('TypeName', value)` (e.g., `isSpecType('CallToolResult', v)`) or `specTypeSchema('TypeName')` for the `StandardSchemaV1` validator object. The `'TypeName'` argument is typed as `SpecTypeName`, a literal union of all spec type names. ### Error class changes @@ -210,7 +211,8 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. -**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with `fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. ### Tools @@ -280,20 +282,20 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata. ### Schema Migration Quick Reference -| v1 (raw shape) | v2 (Standard Schema object) | -|----------------|-----------------| -| `{ name: z.string() }` | `z.object({ name: z.string() })` | +| v1 (raw shape) | v2 (Standard Schema object) | +| ---------------------------------- | -------------------------------------------- | +| `{ name: z.string() }` | `z.object({ name: z.string() })` | | `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | | `{}` (empty) | `z.object({})` | | `undefined` (no schema) | `undefined` or omit the field | ### Removed core exports -| Removed from `@modelcontextprotocol/core` | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed from `@modelcontextprotocol/core` | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | ## 7. Headers API @@ -435,29 +437,33 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} }); Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. -If `CallToolResultSchema` was used for **runtime validation** (not just as a `request()` argument), replace with the `isCallToolResult` type guard: +If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType()` / `specTypeSchema()`: -| v1 pattern | v2 replacement | -| --------------------------------------------------- | -------------------------- | -| `CallToolResultSchema.safeParse(value).success` | `isCallToolResult(value)` | -| `CallToolResultSchema.parse(value)` | Use `isCallToolResult(value)` then cast, or use `CallToolResult` type | +| v1 pattern | v2 replacement | +| -------------------------------------------------- | -------------------------------------------------------------- | +| `CallToolResultSchema.safeParse(value).success` | `isSpecType('CallToolResult', value)` | +| `Schema.safeParse(value).success` | `isSpecType('', value)` | +| `Schema.parse(value)` | `specTypeSchema('')['~standard'].validate(value)` | +| Passing `Schema` as a validator argument | `specTypeSchema('')` (returns `StandardSchemaV1`) | + +`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. ## 12. Experimental: `TaskCreationParams.ttl` no longer accepts `null` `TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. -| v1 | v2 | -|---|---| -| `task: { ttl: null }` | `task: {}` (omit ttl) | +| v1 | v2 | +| ---------------------- | ---------------------------------- | +| `task: { ttl: null }` | `task: {}` (omit ttl) | | `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | Type changes in handler context: -| Type | v1 | v2 | -|---|---|---| -| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| Type | v1 | v2 | +| ------------------------------------------- | ----------------------------- | --------------------- | +| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | | `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | > These task APIs are `@experimental` and may change without notice. @@ -488,6 +494,7 @@ new McpServer({ name: 'server', version: '1.0.0' }, {}); ``` Access validators explicitly: + - Runtime-aware default: `import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';` - AJV (Node.js): `import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server';` - CF Worker: `import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';` diff --git a/docs/migration.md b/docs/migration.md index 7cb7d58f6..0780bd689 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -57,7 +57,8 @@ import { McpServer, StdioServerTransport, WebStandardStreamableHTTPServerTranspo import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; ``` -Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. +Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly +— it is an internal package. ### Dropped Node.js 18 and CommonJS @@ -294,11 +295,11 @@ This applies to: **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): -| Removed | Replacement | -|---|---| -| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | -| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| Removed | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | | `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | ### Host header validation moved @@ -442,18 +443,30 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} }); The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. -If you were using `CallToolResultSchema` for **runtime validation** (not just in `request()`/`callTool()` calls), use the new `isCallToolResult` type guard instead: +If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType()` or `specTypeSchema()`: ```typescript // v1: runtime validation with Zod schema import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -if (CallToolResultSchema.safeParse(value).success) { /* ... */ } +if (CallToolResultSchema.safeParse(value).success) { + /* ... */ +} + +// v2: keyed type predicate +import { isSpecType } from '@modelcontextprotocol/client'; +if (isSpecType('CallToolResult', value)) { + /* ... */ +} -// v2: use the type guard -import { isCallToolResult } from '@modelcontextprotocol/client'; -if (isCallToolResult(value)) { /* ... */ } +// v2: or get the StandardSchemaV1 validator object directly +import { specTypeSchema } from '@modelcontextprotocol/client'; +const schema = specTypeSchema('CallToolResult'); +const result = schema['~standard'].validate(value); ``` +The `name` argument is typed as `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchema()` returns a `StandardSchemaV1`, which composes with any Standard-Schema-aware library and is accepted +by `setCustomRequestHandler`/`sendCustomRequest`. The pre-existing `isCallToolResult(value)` guard still works. + ### Client list methods return empty results for missing capabilities `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, and `listTools()` now return empty results when the server didn't advertise the corresponding capability, instead of sending the request. This respects the MCP spec's capability negotiation. @@ -489,20 +502,21 @@ For **production in-process connections**, use `StreamableHTTPClientTransport` w The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: -| Removed | Replacement | -| ---------------------------------------- | ------------------------------------------------ | -| `JSONRPCError` | `JSONRPCErrorResponse` | -| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | -| `isJSONRPCError` | `isJSONRPCErrorResponse` | -| `isJSONRPCResponse` | `isJSONRPCResultResponse` (see note below) | -| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | -| `ResourceReference` | `ResourceTemplateReference` | -| `IsomorphicHeaders` | Use Web Standard `Headers` | +| Removed | Replacement | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` (see note below) | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `ResourceReference` | `ResourceTemplateReference` | +| `IsomorphicHeaders` | Use Web Standard `Headers` | | `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. -> **Note on `isJSONRPCResponse`:** v1's `isJSONRPCResponse` was a deprecated alias that only checked for *result* responses (it was equivalent to `isJSONRPCResultResponse`). v2 removes the deprecated alias and introduces a **new** `isJSONRPCResponse` with corrected semantics — it checks for *any* response (either result or error). If you are migrating v1 code that used `isJSONRPCResponse`, rename it to `isJSONRPCResultResponse` to preserve the original behavior. Use the new `isJSONRPCResponse` only when you want to match both result and error responses. +> **Note on `isJSONRPCResponse`:** v1's `isJSONRPCResponse` was a deprecated alias that only checked for _result_ responses (it was equivalent to `isJSONRPCResultResponse`). v2 removes the deprecated alias and introduces a **new** `isJSONRPCResponse` with corrected semantics — it +> checks for _any_ response (either result or error). If you are migrating v1 code that used `isJSONRPCResponse`, rename it to `isJSONRPCResultResponse` to preserve the original behavior. Use the new `isJSONRPCResponse` only when you want to match both result and error responses. **Before (v1):** @@ -530,7 +544,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | `extra.authInfo` | `ctx.http?.authInfo` | -| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | @@ -797,7 +811,8 @@ try { ### Experimental: `TaskCreationParams.ttl` no longer accepts `null` -The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let the server decide the lifetime. +The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let +the server decide the lifetime. This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 2dc1e13a8..11f471b66 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -136,7 +136,9 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js'; export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; // Validator types and classes -export type { StandardSchemaWithJSON } from '../../util/standardSchema.js'; +export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; +export { isSpecType, specTypeSchema } from '../../types/specTypeSchema.js'; +export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js'; export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; // fromJsonSchema is intentionally NOT exported here — the server and client packages diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 8349a7496..c150aea73 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -5,4 +5,5 @@ export * from './enums.js'; export * from './errors.js'; export * from './guards.js'; export * from './schemas.js'; +export * from './specTypeSchema.js'; export * from './types.js'; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts new file mode 100644 index 000000000..f0dd78234 --- /dev/null +++ b/packages/core/src/types/specTypeSchema.ts @@ -0,0 +1,88 @@ +import type * as z from 'zod/v4'; + +import * as authSchemas from '../shared/auth.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import * as schemas from './schemas.js'; + +type SchemaModule = typeof schemas & typeof authSchemas; + +type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; + +/** Keys of `schemas.ts` that end in `Schema` and hold a Standard Schema value. */ +type SchemaKey = { + [K in keyof SchemaModule]: K extends `${string}Schema` + ? SchemaModule[K] extends { readonly '~standard': unknown } + ? K + : never + : never; +}[keyof SchemaModule]; + +/** + * Union of every named type in the SDK's protocol and OAuth schemas (e.g. `'CallToolResult'`, + * `'ContentBlock'`, `'Tool'`, `'OAuthTokens'`). Derived from the internal Zod schemas, so it stays + * in sync with the spec. + */ +export type SpecTypeName = StripSchemaSuffix; + +/** + * Maps each {@linkcode SpecTypeName} to its TypeScript type. + * + * `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly. + */ +export type SpecTypes = { + [K in SchemaKey as StripSchemaSuffix]: SchemaModule[K] extends z.ZodType ? T : never; +}; + +const specTypeSchemas: Record = {}; +for (const source of [schemas, authSchemas]) { + for (const [key, value] of Object.entries(source)) { + if (key.endsWith('Schema') && value !== null && typeof value === 'object') { + specTypeSchemas[key.slice(0, -'Schema'.length)] = value as z.ZodTypeAny; + } + } +} + +/** + * Returns a {@linkcode StandardSchemaV1} validator for the named MCP spec type. + * + * Use this when you need to validate a spec-defined shape at a boundary the SDK does not own — + * for example, an extension's custom-method payload that embeds a `CallToolResult`, or a value + * read from storage that should be a `Tool`. + * + * The returned object implements the Standard Schema interface + * (`schema['~standard'].validate(value)`), so it composes with any Standard-Schema-aware library. + * + * @throws {TypeError} if `name` is not a known spec type. + * + * @example + * ```ts + * const schema = specTypeSchema('CallToolResult'); + * const result = schema['~standard'].validate(untrusted); + * if (result.issues === undefined) { + * // result.value is CallToolResult + * } + * ``` + */ +export function specTypeSchema(name: K): StandardSchemaV1 { + const schema = specTypeSchemas[name]; + if (schema === undefined) { + throw new TypeError(`Unknown MCP spec type: "${name}"`); + } + return schema as unknown as StandardSchemaV1; +} + +/** + * Type predicate: returns `true` if `value` structurally matches the named MCP spec type. + * + * Convenience wrapper over {@linkcode specTypeSchema} for boolean checks. + * + * @example + * ```ts + * if (isSpecType('ContentBlock', value)) { + * // value is ContentBlock + * } + * ``` + */ +export function isSpecType(name: K, value: unknown): value is SpecTypes[K] { + return specTypeSchemas[name]?.safeParse(value).success ?? false; +} diff --git a/packages/core/src/types/guards.test.ts b/packages/core/test/types/guards.test.ts similarity index 96% rename from packages/core/src/types/guards.test.ts rename to packages/core/test/types/guards.test.ts index d80f9474a..117e9ecda 100644 --- a/packages/core/src/types/guards.test.ts +++ b/packages/core/test/types/guards.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { JSONRPC_VERSION } from './constants.js'; -import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from './guards.js'; +import { JSONRPC_VERSION } from '../../src/types/constants.js'; +import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from '../../src/types/guards.js'; describe('isJSONRPCResponse', () => { it('returns true for a valid result response', () => { diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts new file mode 100644 index 000000000..c39efc603 --- /dev/null +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js'; +import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js'; +import { isSpecType, specTypeSchema } from '../../src/types/specTypeSchema.js'; +import type { CallToolResult, ContentBlock, Implementation, JSONRPCRequest, Tool } from '../../src/types/types.js'; + +describe('specTypeSchema()', () => { + it('returns a StandardSchemaV1 validator that accepts valid values', () => { + const schema = specTypeSchema('Implementation'); + const result = schema['~standard'].validate({ name: 'x', version: '1.0.0' }); + expect((result as { issues?: unknown }).issues).toBeUndefined(); + }); + + it('returns a validator that rejects invalid values with issues', () => { + const schema = specTypeSchema('Implementation'); + const result = schema['~standard'].validate({ name: 'x' }); + expect((result as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0); + }); + + it('throws TypeError for an unknown name', () => { + expect(() => specTypeSchema('NotASpecType' as SpecTypeName)).toThrow(TypeError); + }); + + it('covers JSON-RPC envelope types', () => { + const ok = specTypeSchema('JSONRPCRequest')['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' }); + expect((ok as { issues?: unknown }).issues).toBeUndefined(); + }); + + it('covers OAuth types from shared/auth.ts', () => { + const ok = specTypeSchema('OAuthTokens')['~standard'].validate({ access_token: 'x', token_type: 'Bearer' }); + expect((ok as { issues?: unknown }).issues).toBeUndefined(); + const bad = specTypeSchema('OAuthTokens')['~standard'].validate({ token_type: 'Bearer' }); + expect((bad as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0); + }); +}); + +describe('isSpecType()', () => { + it('CallToolResult — accepts valid, rejects invalid/null/primitive', () => { + expect(isSpecType('CallToolResult', { content: [{ type: 'text', text: 'hi' }] })).toBe(true); + expect(isSpecType('CallToolResult', { content: 'not-an-array' })).toBe(false); + expect(isSpecType('CallToolResult', null)).toBe(false); + expect(isSpecType('CallToolResult', 'string')).toBe(false); + }); + + it('ContentBlock — accepts text block, rejects wrong shape', () => { + expect(isSpecType('ContentBlock', { type: 'text', text: 'hi' })).toBe(true); + expect(isSpecType('ContentBlock', { type: 'text' })).toBe(false); + expect(isSpecType('ContentBlock', {})).toBe(false); + }); + + it('Tool — accepts valid, rejects missing inputSchema', () => { + expect(isSpecType('Tool', { name: 'echo', inputSchema: { type: 'object' } })).toBe(true); + expect(isSpecType('Tool', { name: 'echo' })).toBe(false); + }); + + it('returns false (not throw) for unknown name', () => { + expect(isSpecType('NotASpecType' as SpecTypeName, {})).toBe(false); + }); + + it('narrows the value type', () => { + const v: unknown = { name: 'x', version: '1.0.0' }; + if (isSpecType('Implementation', v)) { + expectTypeOf(v).toEqualTypeOf(); + } + }); +}); + +describe('SpecTypeName / SpecTypes (type-level)', () => { + it('SpecTypeName includes representative names', () => { + expectTypeOf<'CallToolResult'>().toMatchTypeOf(); + expectTypeOf<'ContentBlock'>().toMatchTypeOf(); + expectTypeOf<'Tool'>().toMatchTypeOf(); + expectTypeOf<'Implementation'>().toMatchTypeOf(); + expectTypeOf<'JSONRPCRequest'>().toMatchTypeOf(); + expectTypeOf<'OAuthTokens'>().toMatchTypeOf(); + expectTypeOf<'OAuthMetadata'>().toMatchTypeOf(); + }); + + it('SpecTypes[K] matches the named export type', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +});