Skip to content

Commit 1219736

Browse files
feat(core): add specTypeSchema() for runtime validation of any spec type
Adds specTypeSchema(name) returning a StandardSchemaV1 validator for any named MCP spec type, and isSpecType(name, value) as a boolean predicate. The SpecTypeName union and SpecTypes map are derived from the internal Zod schemas, so they cover every spec type with no curation. Replaces the earlier curated-5-predicates approach with a single keyed entrypoint that does not require a new export each time an extension embeds a different spec type. Also: moves guards.test.ts to test/ (vitest include is test/**/*.test.ts; the file was previously dead) and corrects CLAUDE.md test-location guidance.
1 parent 1693668 commit 1219736

File tree

7 files changed

+174
-4
lines changed

7 files changed

+174
-4
lines changed

.changeset/spec-type-schema.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
'@modelcontextprotocol/server': patch
4+
---
5+
6+
Export `specTypeSchema(name)` and `isSpecType(name, value)` for runtime validation of any MCP spec type by name. `specTypeSchema` returns a `StandardSchemaV1<T>` validator; `isSpecType` is a boolean type predicate. Also export the `StandardSchemaV1`, `SpecTypeName`, and `SpecTypes` types.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Include what changed, why, and how to migrate. Search for related sections and g
3838
- **Files**: Lowercase with hyphens, test files with `.test.ts` suffix
3939
- **Imports**: ES module style, include `.js` extension, group imports logically
4040
- **Formatting**: 2-space indentation, semicolons required, single quotes preferred
41-
- **Testing**: Co-locate tests with source files, use descriptive test names
41+
- **Testing**: Place tests under each package's `test/` directory (vitest only includes `test/**/*.test.ts`), use descriptive test names
4242
- **Comments**: JSDoc for public APIs, inline comments for complex logic
4343

4444
### JSDoc `@example` Code Snippets

packages/core/src/exports/public/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ export { isTerminal } from '../../experimental/tasks/interfaces.js';
136136
export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js';
137137

138138
// Validator types and classes
139-
export type { StandardSchemaWithJSON } from '../../util/standardSchema.js';
139+
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
140+
export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js';
141+
export { isSpecType, specTypeSchema } from '../../types/specTypeSchema.js';
140142
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
141143
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';
142144
// fromJsonSchema is intentionally NOT exported here — the server and client packages

packages/core/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export * from './enums.js';
55
export * from './errors.js';
66
export * from './guards.js';
77
export * from './schemas.js';
8+
export * from './specTypeSchema.js';
89
export * from './types.js';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type * as z from 'zod/v4';
2+
3+
import type { StandardSchemaV1 } from '../util/standardSchema.js';
4+
import * as schemas from './schemas.js';
5+
6+
type SchemaModule = typeof schemas;
7+
8+
type StripSchemaSuffix<K> = K extends `${infer N}Schema` ? N : never;
9+
10+
/** Keys of `schemas.ts` that end in `Schema` and hold a Standard Schema value. */
11+
type SchemaKey = {
12+
[K in keyof SchemaModule]: K extends `${string}Schema`
13+
? SchemaModule[K] extends { readonly '~standard': unknown }
14+
? K
15+
: never
16+
: never;
17+
}[keyof SchemaModule];
18+
19+
/**
20+
* Union of every named type in the MCP spec schema (e.g. `'CallToolResult'`, `'ContentBlock'`,
21+
* `'Tool'`). Derived from the SDK's internal Zod schemas, so it stays in sync with the spec.
22+
*/
23+
export type SpecTypeName = StripSchemaSuffix<SchemaKey>;
24+
25+
/**
26+
* Maps each {@linkcode SpecTypeName} to its TypeScript type.
27+
*
28+
* `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly.
29+
*/
30+
export type SpecTypes = {
31+
[K in SchemaKey as StripSchemaSuffix<K>]: SchemaModule[K] extends z.ZodType<infer T> ? T : never;
32+
};
33+
34+
const specTypeSchemas: Record<string, z.ZodTypeAny> = {};
35+
for (const [key, value] of Object.entries(schemas)) {
36+
if (key.endsWith('Schema') && value !== null && typeof value === 'object') {
37+
specTypeSchemas[key.slice(0, -'Schema'.length)] = value as z.ZodTypeAny;
38+
}
39+
}
40+
41+
/**
42+
* Returns a {@linkcode StandardSchemaV1} validator for the named MCP spec type.
43+
*
44+
* Use this when you need to validate a spec-defined shape at a boundary the SDK does not own —
45+
* for example, an extension's custom-method payload that embeds a `CallToolResult`, or a value
46+
* read from storage that should be a `Tool`.
47+
*
48+
* The returned object implements the Standard Schema interface
49+
* (`schema['~standard'].validate(value)`), so it composes with any Standard-Schema-aware library.
50+
*
51+
* @throws {TypeError} if `name` is not a known spec type.
52+
*
53+
* @example
54+
* ```ts
55+
* const schema = specTypeSchema('CallToolResult');
56+
* const result = schema['~standard'].validate(untrusted);
57+
* if (result.issues === undefined) {
58+
* // result.value is CallToolResult
59+
* }
60+
* ```
61+
*/
62+
export function specTypeSchema<K extends SpecTypeName>(name: K): StandardSchemaV1<SpecTypes[K]> {
63+
const schema = specTypeSchemas[name];
64+
if (schema === undefined) {
65+
throw new TypeError(`Unknown MCP spec type: "${name}"`);
66+
}
67+
return schema as unknown as StandardSchemaV1<SpecTypes[K]>;
68+
}
69+
70+
/**
71+
* Type predicate: returns `true` if `value` structurally matches the named MCP spec type.
72+
*
73+
* Convenience wrapper over {@linkcode specTypeSchema} for boolean checks.
74+
*
75+
* @example
76+
* ```ts
77+
* if (isSpecType('ContentBlock', value)) {
78+
* // value is ContentBlock
79+
* }
80+
* ```
81+
*/
82+
export function isSpecType<K extends SpecTypeName>(name: K, value: unknown): value is SpecTypes[K] {
83+
return specTypeSchemas[name]?.safeParse(value).success ?? false;
84+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { JSONRPC_VERSION } from './constants.js';
4-
import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from './guards.js';
3+
import { JSONRPC_VERSION } from '../../src/types/constants.js';
4+
import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from '../../src/types/guards.js';
55

66
describe('isJSONRPCResponse', () => {
77
it('returns true for a valid result response', () => {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, expectTypeOf, it } from 'vitest';
2+
3+
import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js';
4+
import { isSpecType, specTypeSchema } from '../../src/types/specTypeSchema.js';
5+
import type { CallToolResult, ContentBlock, Implementation, JSONRPCRequest, Tool } from '../../src/types/types.js';
6+
7+
describe('specTypeSchema()', () => {
8+
it('returns a StandardSchemaV1 validator that accepts valid values', () => {
9+
const schema = specTypeSchema('Implementation');
10+
const result = schema['~standard'].validate({ name: 'x', version: '1.0.0' });
11+
expect((result as { issues?: unknown }).issues).toBeUndefined();
12+
});
13+
14+
it('returns a validator that rejects invalid values with issues', () => {
15+
const schema = specTypeSchema('Implementation');
16+
const result = schema['~standard'].validate({ name: 'x' });
17+
expect((result as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0);
18+
});
19+
20+
it('throws TypeError for an unknown name', () => {
21+
expect(() => specTypeSchema('NotASpecType' as SpecTypeName)).toThrow(TypeError);
22+
});
23+
24+
it('covers JSON-RPC envelope types', () => {
25+
const ok = specTypeSchema('JSONRPCRequest')['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' });
26+
expect((ok as { issues?: unknown }).issues).toBeUndefined();
27+
});
28+
});
29+
30+
describe('isSpecType()', () => {
31+
it('CallToolResult — accepts valid, rejects invalid/null/primitive', () => {
32+
expect(isSpecType('CallToolResult', { content: [{ type: 'text', text: 'hi' }] })).toBe(true);
33+
expect(isSpecType('CallToolResult', { content: 'not-an-array' })).toBe(false);
34+
expect(isSpecType('CallToolResult', null)).toBe(false);
35+
expect(isSpecType('CallToolResult', 'string')).toBe(false);
36+
});
37+
38+
it('ContentBlock — accepts text block, rejects wrong shape', () => {
39+
expect(isSpecType('ContentBlock', { type: 'text', text: 'hi' })).toBe(true);
40+
expect(isSpecType('ContentBlock', { type: 'text' })).toBe(false);
41+
expect(isSpecType('ContentBlock', {})).toBe(false);
42+
});
43+
44+
it('Tool — accepts valid, rejects missing inputSchema', () => {
45+
expect(isSpecType('Tool', { name: 'echo', inputSchema: { type: 'object' } })).toBe(true);
46+
expect(isSpecType('Tool', { name: 'echo' })).toBe(false);
47+
});
48+
49+
it('returns false (not throw) for unknown name', () => {
50+
expect(isSpecType('NotASpecType' as SpecTypeName, {})).toBe(false);
51+
});
52+
53+
it('narrows the value type', () => {
54+
const v: unknown = { name: 'x', version: '1.0.0' };
55+
if (isSpecType('Implementation', v)) {
56+
expectTypeOf(v).toEqualTypeOf<SpecTypes['Implementation']>();
57+
}
58+
});
59+
});
60+
61+
describe('SpecTypeName / SpecTypes (type-level)', () => {
62+
it('SpecTypeName includes representative names', () => {
63+
expectTypeOf<'CallToolResult'>().toMatchTypeOf<SpecTypeName>();
64+
expectTypeOf<'ContentBlock'>().toMatchTypeOf<SpecTypeName>();
65+
expectTypeOf<'Tool'>().toMatchTypeOf<SpecTypeName>();
66+
expectTypeOf<'Implementation'>().toMatchTypeOf<SpecTypeName>();
67+
expectTypeOf<'JSONRPCRequest'>().toMatchTypeOf<SpecTypeName>();
68+
});
69+
70+
it('SpecTypes[K] matches the named export type', () => {
71+
expectTypeOf<SpecTypes['CallToolResult']>().toEqualTypeOf<CallToolResult>();
72+
expectTypeOf<SpecTypes['ContentBlock']>().toEqualTypeOf<ContentBlock>();
73+
expectTypeOf<SpecTypes['Tool']>().toEqualTypeOf<Tool>();
74+
expectTypeOf<SpecTypes['Implementation']>().toEqualTypeOf<Implementation>();
75+
expectTypeOf<SpecTypes['JSONRPCRequest']>().toEqualTypeOf<JSONRPCRequest>();
76+
});
77+
});

0 commit comments

Comments
 (0)