Skip to content

Commit 4bef047

Browse files
refactor(core): Record-form isSpecType/specTypeSchemas instead of specTypeSchema()/isSpecType()
1 parent 5e77869 commit 4bef047

6 files changed

Lines changed: 71 additions & 65 deletions

File tree

.changeset/spec-type-schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'@modelcontextprotocol/server': patch
44
---
55

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.
6+
Export `isSpecType` and `specTypeSchemas` records for runtime validation of any MCP spec type by name. `isSpecType.ContentBlock(value)` is a type predicate; `specTypeSchemas.ContentBlock` is a `StandardSchemaV1<ContentBlock>` validator. Guards are standalone functions, so `arr.filter(isSpecType.ContentBlock)` works. Also export the `StandardSchemaV1`, `SpecTypeName`, and `SpecTypes` types.

docs/migration-SKILL.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Notes:
9999
| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) |
100100

101101
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
102-
`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.
102+
`isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` for the `StandardSchemaV1` validator object. The keys are typed as `SpecTypeName`, a literal union of all spec type names.
103103

104104
### Error class changes
105105

@@ -437,14 +437,14 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} });
437437

438438
Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls.
439439

440-
If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType()` / `specTypeSchema()`:
440+
If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`:
441441

442-
| v1 pattern | v2 replacement |
443-
| -------------------------------------------------- | -------------------------------------------------------------- |
444-
| `CallToolResultSchema.safeParse(value).success` | `isSpecType('CallToolResult', value)` |
445-
| `<TypeName>Schema.safeParse(value).success` | `isSpecType('<TypeName>', value)` |
446-
| `<TypeName>Schema.parse(value)` | `specTypeSchema('<TypeName>')['~standard'].validate(value)` |
447-
| Passing `<TypeName>Schema` as a validator argument | `specTypeSchema('<TypeName>')` (returns `StandardSchemaV1<T>`) |
442+
| v1 pattern | v2 replacement |
443+
| -------------------------------------------------- | ------------------------------------------------------------ |
444+
| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` |
445+
| `<TypeName>Schema.safeParse(value).success` | `isSpecType.<TypeName>(value)` |
446+
| `<TypeName>Schema.parse(value)` | `specTypeSchemas.<TypeName>['~standard'].validate(value)` |
447+
| Passing `<TypeName>Schema` as a validator argument | `specTypeSchemas.<TypeName>` (returns `StandardSchemaV1<T>`) |
448448

449449
`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name.
450450

docs/migration.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} });
443443

444444
The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult | CreateTaskResult>`.
445445

446-
If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType()` or `specTypeSchema()`:
446+
If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType` or `specTypeSchemas`:
447447

448448
```typescript
449449
// v1: runtime validation with Zod schema
@@ -454,17 +454,17 @@ if (CallToolResultSchema.safeParse(value).success) {
454454

455455
// v2: keyed type predicate
456456
import { isSpecType } from '@modelcontextprotocol/client';
457-
if (isSpecType('CallToolResult', value)) {
457+
if (isSpecType.CallToolResult(value)) {
458458
/* ... */
459459
}
460+
const blocks = mixed.filter(isSpecType.ContentBlock);
460461

461462
// v2: or get the StandardSchemaV1 validator object directly
462-
import { specTypeSchema } from '@modelcontextprotocol/client';
463-
const schema = specTypeSchema('CallToolResult');
464-
const result = schema['~standard'].validate(value);
463+
import { specTypeSchemas } from '@modelcontextprotocol/client';
464+
const result = specTypeSchemas.CallToolResult['~standard'].validate(value);
465465
```
466466

467-
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<T>`, which composes with any Standard-Schema-aware library and is accepted
467+
`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 `StandardSchemaV1<T>`, which composes with any Standard-Schema-aware library and is accepted
468468
by `setCustomRequestHandler`/`sendCustomRequest`. The pre-existing `isCallToolResult(value)` guard still works.
469469

470470
### Client list methods return empty results for missing capabilities

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/
138138

139139
// Validator types and classes
140140
export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js';
141-
export { isSpecType, specTypeSchema } from '../../types/specTypeSchema.js';
141+
export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js';
142142
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
143143
export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js';
144144
export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js';

packages/core/src/types/specTypeSchema.ts

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,56 +33,55 @@ export type SpecTypes = {
3333
[K in SchemaKey as StripSchemaSuffix<K>]: SchemaModule[K] extends z.ZodType<infer T> ? T : never;
3434
};
3535

36-
const specTypeSchemas: Record<string, z.ZodTypeAny> = {};
36+
type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1<SpecTypes[K]> };
37+
type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypes[K] };
38+
39+
const _specTypeSchemas: Record<string, z.ZodTypeAny> = {};
40+
const _isSpecType: Record<string, (value: unknown) => boolean> = {};
3741
for (const source of [schemas, authSchemas]) {
3842
for (const [key, value] of Object.entries(source)) {
3943
if (key.endsWith('Schema') && value !== null && typeof value === 'object') {
40-
specTypeSchemas[key.slice(0, -'Schema'.length)] = value as z.ZodTypeAny;
44+
const name = key.slice(0, -'Schema'.length);
45+
const schema = value as z.ZodTypeAny;
46+
_specTypeSchemas[name] = schema;
47+
_isSpecType[name] = (v: unknown) => schema.safeParse(v).success;
4148
}
4249
}
4350
}
4451

4552
/**
46-
* Returns a {@linkcode StandardSchemaV1} validator for the named MCP spec type.
47-
*
48-
* Use this when you need to validate a spec-defined shape at a boundary the SDK does not own —
49-
* for example, an extension's custom-method payload that embeds a `CallToolResult`, or a value
50-
* read from storage that should be a `Tool`.
53+
* Runtime validators for every MCP spec type, keyed by type name.
5154
*
52-
* The returned object implements the Standard Schema interface
53-
* (`schema['~standard'].validate(value)`), so it composes with any Standard-Schema-aware library.
55+
* Use this when you need to validate a spec-defined shape at a boundary the SDK does not own, for
56+
* example an extension's custom-method payload that embeds a `CallToolResult`, or a value read from
57+
* storage that should be a `Tool`.
5458
*
55-
* @throws {TypeError} if `name` is not a known spec type.
59+
* Each entry implements the Standard Schema interface (`schema['~standard'].validate(value)`), so it
60+
* composes with any Standard-Schema-aware library.
5661
*
5762
* @example
5863
* ```ts
59-
* const schema = specTypeSchema('CallToolResult');
60-
* const result = schema['~standard'].validate(untrusted);
64+
* const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted);
6165
* if (result.issues === undefined) {
6266
* // result.value is CallToolResult
6367
* }
6468
* ```
6569
*/
66-
export function specTypeSchema<K extends SpecTypeName>(name: K): StandardSchemaV1<SpecTypes[K]> {
67-
const schema = specTypeSchemas[name];
68-
if (schema === undefined) {
69-
throw new TypeError(`Unknown MCP spec type: "${name}"`);
70-
}
71-
return schema as unknown as StandardSchemaV1<SpecTypes[K]>;
72-
}
70+
export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas) as unknown as SchemaRecord;
7371

7472
/**
75-
* Type predicate: returns `true` if `value` structurally matches the named MCP spec type.
73+
* Type predicates for every MCP spec type, keyed by type name.
7674
*
77-
* Convenience wrapper over {@linkcode specTypeSchema} for boolean checks.
75+
* Returns `true` if the value structurally matches the named spec type. Each guard is a standalone
76+
* function, so it can be passed directly as a callback.
7877
*
7978
* @example
8079
* ```ts
81-
* if (isSpecType('ContentBlock', value)) {
80+
* if (isSpecType.ContentBlock(value)) {
8281
* // value is ContentBlock
8382
* }
83+
*
84+
* const blocks = mixed.filter(isSpecType.ContentBlock);
8485
* ```
8586
*/
86-
export function isSpecType<K extends SpecTypeName>(name: K, value: unknown): value is SpecTypes[K] {
87-
return specTypeSchemas[name]?.safeParse(value).success ?? false;
88-
}
87+
export const isSpecType: GuardRecord = Object.freeze(_isSpecType) as unknown as GuardRecord;

packages/core/test/types/specTypeSchema.test.ts

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,75 @@ import { describe, expect, expectTypeOf, it } from 'vitest';
22

33
import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js';
44
import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js';
5-
import { isSpecType, specTypeSchema } from '../../src/types/specTypeSchema.js';
5+
import { isSpecType, specTypeSchemas } from '../../src/types/specTypeSchema.js';
66
import type { CallToolResult, ContentBlock, Implementation, JSONRPCRequest, Tool } from '../../src/types/types.js';
77

8-
describe('specTypeSchema()', () => {
8+
describe('specTypeSchemas', () => {
99
it('returns a StandardSchemaV1 validator that accepts valid values', () => {
10-
const schema = specTypeSchema('Implementation');
11-
const result = schema['~standard'].validate({ name: 'x', version: '1.0.0' });
10+
const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x', version: '1.0.0' });
1211
expect((result as { issues?: unknown }).issues).toBeUndefined();
1312
});
1413

1514
it('returns a validator that rejects invalid values with issues', () => {
16-
const schema = specTypeSchema('Implementation');
17-
const result = schema['~standard'].validate({ name: 'x' });
15+
const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x' });
1816
expect((result as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0);
1917
});
2018

21-
it('throws TypeError for an unknown name', () => {
22-
expect(() => specTypeSchema('NotASpecType' as SpecTypeName)).toThrow(TypeError);
19+
it('rejects unknown names at compile time and is undefined at runtime', () => {
20+
// @ts-expect-error - 'NotASpecType' is not a SpecTypeName
21+
expect(specTypeSchemas['NotASpecType']).toBeUndefined();
2322
});
2423

2524
it('covers JSON-RPC envelope types', () => {
26-
const ok = specTypeSchema('JSONRPCRequest')['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' });
25+
const ok = specTypeSchemas.JSONRPCRequest['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' });
2726
expect((ok as { issues?: unknown }).issues).toBeUndefined();
2827
});
2928

3029
it('covers OAuth types from shared/auth.ts', () => {
31-
const ok = specTypeSchema('OAuthTokens')['~standard'].validate({ access_token: 'x', token_type: 'Bearer' });
30+
const ok = specTypeSchemas.OAuthTokens['~standard'].validate({ access_token: 'x', token_type: 'Bearer' });
3231
expect((ok as { issues?: unknown }).issues).toBeUndefined();
33-
const bad = specTypeSchema('OAuthTokens')['~standard'].validate({ token_type: 'Bearer' });
32+
const bad = specTypeSchemas.OAuthTokens['~standard'].validate({ token_type: 'Bearer' });
3433
expect((bad as { issues?: readonly unknown[] }).issues?.length).toBeGreaterThan(0);
3534
});
3635
});
3736

38-
describe('isSpecType()', () => {
37+
describe('isSpecType', () => {
3938
it('CallToolResult — accepts valid, rejects invalid/null/primitive', () => {
40-
expect(isSpecType('CallToolResult', { content: [{ type: 'text', text: 'hi' }] })).toBe(true);
41-
expect(isSpecType('CallToolResult', { content: 'not-an-array' })).toBe(false);
42-
expect(isSpecType('CallToolResult', null)).toBe(false);
43-
expect(isSpecType('CallToolResult', 'string')).toBe(false);
39+
expect(isSpecType.CallToolResult({ content: [{ type: 'text', text: 'hi' }] })).toBe(true);
40+
expect(isSpecType.CallToolResult({ content: 'not-an-array' })).toBe(false);
41+
expect(isSpecType.CallToolResult(null)).toBe(false);
42+
expect(isSpecType.CallToolResult('string')).toBe(false);
4443
});
4544

4645
it('ContentBlock — accepts text block, rejects wrong shape', () => {
47-
expect(isSpecType('ContentBlock', { type: 'text', text: 'hi' })).toBe(true);
48-
expect(isSpecType('ContentBlock', { type: 'text' })).toBe(false);
49-
expect(isSpecType('ContentBlock', {})).toBe(false);
46+
expect(isSpecType.ContentBlock({ type: 'text', text: 'hi' })).toBe(true);
47+
expect(isSpecType.ContentBlock({ type: 'text' })).toBe(false);
48+
expect(isSpecType.ContentBlock({})).toBe(false);
5049
});
5150

5251
it('Tool — accepts valid, rejects missing inputSchema', () => {
53-
expect(isSpecType('Tool', { name: 'echo', inputSchema: { type: 'object' } })).toBe(true);
54-
expect(isSpecType('Tool', { name: 'echo' })).toBe(false);
52+
expect(isSpecType.Tool({ name: 'echo', inputSchema: { type: 'object' } })).toBe(true);
53+
expect(isSpecType.Tool({ name: 'echo' })).toBe(false);
5554
});
5655

57-
it('returns false (not throw) for unknown name', () => {
58-
expect(isSpecType('NotASpecType' as SpecTypeName, {})).toBe(false);
56+
it('rejects unknown names at compile time and is undefined at runtime', () => {
57+
// @ts-expect-error - 'NotASpecType' is not a SpecTypeName
58+
expect(isSpecType['NotASpecType']).toBeUndefined();
5959
});
6060

6161
it('narrows the value type', () => {
6262
const v: unknown = { name: 'x', version: '1.0.0' };
63-
if (isSpecType('Implementation', v)) {
63+
if (isSpecType.Implementation(v)) {
6464
expectTypeOf(v).toEqualTypeOf<SpecTypes['Implementation']>();
6565
}
6666
});
67+
68+
it('guards work as filter callbacks and narrow the element type', () => {
69+
const mixed: unknown[] = [{ type: 'text', text: 'hi' }, 42, { type: 'text' }];
70+
const blocks = mixed.filter(isSpecType.ContentBlock);
71+
expect(blocks).toHaveLength(1);
72+
expectTypeOf(blocks).toEqualTypeOf<ContentBlock[]>();
73+
});
6774
});
6875

6976
describe('SpecTypeName / SpecTypes (type-level)', () => {

0 commit comments

Comments
 (0)