Skip to content

Commit cd84edd

Browse files
authored
Merge branch 'main' into fix/scope-overwrite-infinite-reauth
2 parents fae043e + 9aed95a commit cd84edd

File tree

30 files changed

+1874
-224
lines changed

30 files changed

+1874
-224
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/server': minor
4+
'@modelcontextprotocol/client': minor
5+
---
6+
7+
Support Standard Schema for tool and prompt schemas
8+
9+
Tool and prompt registration now accepts any schema library that implements the [Standard Schema spec](https://standardschema.dev/): Zod v4, Valibot, ArkType, and others. `RegisteredTool.inputSchema`, `RegisteredTool.outputSchema`, and `RegisteredPrompt.argsSchema` now use `StandardSchemaWithJSON` (requires both `~standard.validate` and `~standard.jsonSchema`) instead of the Zod-specific `AnySchema` type.
10+
11+
**Zod v4 schemas continue to work unchanged** — Zod v4 implements the required interfaces natively.
12+
13+
```typescript
14+
import { type } from 'arktype';
15+
16+
server.registerTool('greet', {
17+
inputSchema: type({ name: 'string' })
18+
}, async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }));
19+
```
20+
21+
For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter:
22+
23+
```typescript
24+
import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core';
25+
26+
server.registerTool('greet', {
27+
inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator())
28+
}, handler);
29+
```
30+
31+
**Breaking changes:**
32+
- `experimental.tasks.getTaskResult()` no longer accepts a `resultSchema` parameter. Returns `GetTaskPayloadResult` (a loose `Result`); cast to the expected type at the call site.
33+
- Removed unused exports from `@modelcontextprotocol/core`: `SchemaInput`, `schemaToJson`, `parseSchemaAsync`, `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema`. Use the new `standardSchemaToJsonSchema` and `validateStandardSchema` instead.
34+
- `completable()` remains Zod-specific (it relies on Zod's `.shape` introspection).

docs/migration-SKILL.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient)
209209

210210
The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object.
211211

212-
**IMPORTANT**: v2 requires full Zod schemas — raw shapes like `{ name: z.string() }` are no longer supported. You must wrap with `z.object()`. This applies to `inputSchema`, `outputSchema`, and `argsSchema`.
212+
**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, validator)` from `@modelcontextprotocol/core`. Applies to `inputSchema`, `outputSchema`, and `argsSchema`.
213213

214214
### Tools
215215

@@ -279,13 +279,22 @@ Note: the third argument (`metadata`) is required — pass `{}` if no metadata.
279279

280280
### Schema Migration Quick Reference
281281

282-
| v1 (raw shape) | v2 (Zod schema) |
282+
| v1 (raw shape) | v2 (Standard Schema object) |
283283
|----------------|-----------------|
284284
| `{ name: z.string() }` | `z.object({ name: z.string() })` |
285285
| `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` |
286286
| `{}` (empty) | `z.object({})` |
287287
| `undefined` (no schema) | `undefined` or omit the field |
288288

289+
### Removed core exports
290+
291+
| Removed from `@modelcontextprotocol/core` | Replacement |
292+
|---|---|
293+
| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` |
294+
| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` |
295+
| `SchemaInput<T>` | `StandardSchemaWithJSON.InferInput<T>` |
296+
| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) |
297+
289298
## 7. Headers API
290299

291300
Transport constructors and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain objects.

docs/migration.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,17 @@ import * as z from 'zod/v4';
200200
const server = new McpServer({ name: 'demo', version: '1.0.0' });
201201

202202
// Tool with schema
203-
server.registerTool('greet', { inputSchema: { name: z.string() } }, async ({ name }) => {
203+
server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }) => {
204204
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
205205
});
206206

207207
// Tool with description
208-
server.registerTool('greet', { description: 'Greet a user', inputSchema: { name: z.string() } }, async ({ name }) => {
208+
server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => {
209209
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
210210
});
211211

212212
// Prompt
213-
server.registerPrompt('summarize', { argsSchema: { text: z.string() } }, async ({ text }) => {
213+
server.registerPrompt('summarize', { argsSchema: z.object({ text: z.string() }) }, async ({ text }) => {
214214
return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }] };
215215
});
216216

@@ -220,9 +220,9 @@ server.registerResource('config', 'config://app', {}, async uri => {
220220
});
221221
```
222222

223-
### Zod schemas required (raw shapes no longer supported)
223+
### Standard Schema objects required (raw shapes no longer supported)
224224

225-
v2 requires full Zod schemas for `inputSchema` and `argsSchema`. Raw object shapes are no longer accepted.
225+
v2 requires schema objects implementing the [Standard Schema spec](https://standardschema.dev/) for `inputSchema`, `outputSchema`, and `argsSchema`. Raw object shapes are no longer accepted. Zod v4, ArkType, and Valibot all implement the spec.
226226

227227
**Before (v1):**
228228

@@ -240,11 +240,23 @@ server.registerTool('greet', {
240240
```typescript
241241
import * as z from 'zod/v4';
242242

243-
// Must wrap with z.object()
243+
// Wrap with z.object() (or use any Standard Schema library)
244244
server.registerTool('greet', {
245-
inputSchema: z.object({ name: z.string() }) // full Zod schema
245+
inputSchema: z.object({ name: z.string() })
246246
}, async ({ name }) => { ... });
247247

248+
// ArkType works too
249+
import { type } from 'arktype';
250+
server.registerTool('greet', {
251+
inputSchema: type({ name: 'string' })
252+
}, async ({ name }) => { ... });
253+
254+
// Raw JSON Schema via fromJsonSchema
255+
import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core';
256+
server.registerTool('greet', {
257+
inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator())
258+
}, handler);
259+
248260
// For tools with no parameters, use z.object({})
249261
server.registerTool('ping', {
250262
inputSchema: z.object({})
@@ -256,6 +268,15 @@ This applies to:
256268
- `outputSchema` in `registerTool()`
257269
- `argsSchema` in `registerPrompt()`
258270

271+
**Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents):
272+
273+
| Removed | Replacement |
274+
|---|---|
275+
| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` |
276+
| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` |
277+
| `SchemaInput<T>` | `StandardSchemaWithJSON.InferInput<T>` |
278+
| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers |
279+
259280
### Host header validation moved
260281

261282
Express-specific middleware (`hostHeaderValidation()`, `localhostHostValidation()`) moved from the server package to `@modelcontextprotocol/express`. The server package now exports framework-agnostic functions instead: `validateHostHeader()`, `localhostAllowedHostnames()`,

examples/server/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@
3434
"dependencies": {
3535
"@hono/node-server": "catalog:runtimeServerOnly",
3636
"@modelcontextprotocol/examples-shared": "workspace:^",
37-
"@modelcontextprotocol/node": "workspace:^",
38-
"@modelcontextprotocol/server": "workspace:^",
3937
"@modelcontextprotocol/express": "workspace:^",
4038
"@modelcontextprotocol/hono": "workspace:^",
39+
"@modelcontextprotocol/node": "workspace:^",
40+
"@modelcontextprotocol/server": "workspace:^",
41+
"@valibot/to-json-schema": "catalog:devTools",
42+
"arktype": "catalog:devTools",
4143
"better-auth": "^1.4.17",
4244
"cors": "catalog:runtimeServerOnly",
4345
"express": "catalog:runtimeServerOnly",
4446
"hono": "catalog:runtimeServerOnly",
47+
"valibot": "catalog:devTools",
4548
"zod": "catalog:runtimeShared"
4649
},
4750
"devDependencies": {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Minimal MCP server using ArkType for schema validation.
4+
* ArkType implements the Standard Schema spec with built-in JSON Schema conversion.
5+
*/
6+
7+
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
8+
import { type } from 'arktype';
9+
10+
const server = new McpServer({
11+
name: 'arktype-example',
12+
version: '1.0.0'
13+
});
14+
15+
// Register a tool with ArkType schema
16+
server.registerTool(
17+
'greet',
18+
{
19+
description: 'Generate a greeting',
20+
inputSchema: type({ name: 'string' })
21+
},
22+
async ({ name }) => ({
23+
content: [{ type: 'text', text: `Hello, ${name}!` }]
24+
})
25+
);
26+
27+
const transport = new StdioServerTransport();
28+
await server.connect(transport);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Minimal MCP server using Valibot for schema validation.
4+
* Use toStandardJsonSchema() from @valibot/to-json-schema to create
5+
* StandardJSONSchemaV1-compliant schemas.
6+
*/
7+
8+
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
9+
import { toStandardJsonSchema } from '@valibot/to-json-schema';
10+
import * as v from 'valibot';
11+
12+
const server = new McpServer({
13+
name: 'valibot-example',
14+
version: '1.0.0'
15+
});
16+
17+
// Register a tool with Valibot schema
18+
server.registerTool(
19+
'greet',
20+
{
21+
description: 'Generate a greeting',
22+
inputSchema: toStandardJsonSchema(v.object({ name: v.string() }))
23+
},
24+
async ({ name }) => ({
25+
content: [{ type: 'text', text: `Hello, ${name}!` }]
26+
})
27+
);
28+
29+
const transport = new StdioServerTransport();
30+
await server.connect(transport);

packages/client/src/client/client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export type ClientOptions = ProtocolOptions & {
194194
export class Client extends Protocol<ClientContext> {
195195
private _serverCapabilities?: ServerCapabilities;
196196
private _serverVersion?: Implementation;
197+
private _negotiatedProtocolVersion?: string;
197198
private _capabilities: ClientCapabilities;
198199
private _instructions?: string;
199200
private _jsonSchemaValidator: jsonSchemaValidator;
@@ -470,8 +471,12 @@ export class Client extends Protocol<ClientContext> {
470471
override async connect(transport: Transport, options?: RequestOptions): Promise<void> {
471472
await super.connect(transport);
472473
// When transport sessionId is already set this means we are trying to reconnect.
473-
// In this case we don't need to initialize again.
474+
// Restore the protocol version negotiated during the original initialize handshake
475+
// so HTTP transports include the required mcp-protocol-version header, but skip re-init.
474476
if (transport.sessionId !== undefined) {
477+
if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) {
478+
transport.setProtocolVersion(this._negotiatedProtocolVersion);
479+
}
475480
return;
476481
}
477482
try {
@@ -498,6 +503,7 @@ export class Client extends Protocol<ClientContext> {
498503

499504
this._serverCapabilities = result.capabilities;
500505
this._serverVersion = result.serverInfo;
506+
this._negotiatedProtocolVersion = result.protocolVersion;
501507
// HTTP transports must set the protocol version in each header after initialization.
502508
if (transport.setProtocolVersion) {
503509
transport.setProtocolVersion(result.protocolVersion);
@@ -535,6 +541,15 @@ export class Client extends Protocol<ClientContext> {
535541
return this._serverVersion;
536542
}
537543

544+
/**
545+
* After initialization has completed, this will be populated with the protocol version negotiated
546+
* during the initialize handshake. When manually reconstructing a transport for reconnection, pass this
547+
* value to the new transport so it continues sending the required `mcp-protocol-version` header.
548+
*/
549+
getNegotiatedProtocolVersion(): string | undefined {
550+
return this._negotiatedProtocolVersion;
551+
}
552+
538553
/**
539554
* After initialization has completed, this may be populated with information about the server's instructions.
540555
*/

packages/client/src/client/streamableHttp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ export type StreamableHTTPClientTransportOptions = {
133133
* When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
134134
*/
135135
sessionId?: string;
136+
137+
/**
138+
* The MCP protocol version to include in the `mcp-protocol-version` header on all requests.
139+
* When reconnecting with a preserved `sessionId`, set this to the version negotiated during the original
140+
* handshake so the reconnected transport continues sending the required header.
141+
*/
142+
protocolVersion?: string;
136143
};
137144

138145
/**
@@ -170,6 +177,7 @@ export class StreamableHTTPClientTransport implements Transport {
170177
this._fetch = opts?.fetch;
171178
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
172179
this._sessionId = opts?.sessionId;
180+
this._protocolVersion = opts?.protocolVersion;
173181
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
174182
}
175183

packages/client/src/experimental/tasks/client.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,19 @@
66
*/
77

88
import type {
9-
AnyObjectSchema,
109
CallToolRequest,
1110
CallToolResult,
1211
CancelTaskResult,
1312
CreateTaskResult,
13+
GetTaskPayloadResult,
1414
GetTaskResult,
1515
ListTasksResult,
1616
RequestMethod,
1717
RequestOptions,
1818
ResponseMessage,
19-
ResultTypeMap,
20-
SchemaOutput
19+
ResultTypeMap
2120
} from '@modelcontextprotocol/core';
22-
import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core';
21+
import { GetTaskPayloadResultSchema, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core';
2322

2423
import type { Client } from '../../client/client.js';
2524

@@ -185,23 +184,22 @@ export class ExperimentalClientTasks {
185184
* Retrieves the result of a completed task.
186185
*
187186
* @param taskId - The task identifier
188-
* @param resultSchema - Zod schema for validating the result
189187
* @param options - Optional request options
190-
* @returns The task result
188+
* @returns The task result. The payload structure matches the result type of the
189+
* original request (e.g., a `tools/call` task returns a `CallToolResult`).
191190
*
192191
* @experimental
193192
*/
194-
async getTaskResult<T extends AnyObjectSchema>(taskId: string, resultSchema?: T, options?: RequestOptions): Promise<SchemaOutput<T>> {
195-
// Delegate to the client's underlying Protocol method
193+
async getTaskResult(taskId: string, options?: RequestOptions): Promise<GetTaskPayloadResult> {
196194
return (
197195
this._client as unknown as {
198-
getTaskResult: <U extends AnyObjectSchema>(
196+
getTaskResult: (
199197
params: { taskId: string },
200-
resultSchema?: U,
198+
resultSchema: typeof GetTaskPayloadResultSchema,
201199
options?: RequestOptions
202-
) => Promise<SchemaOutput<U>>;
200+
) => Promise<GetTaskPayloadResult>;
203201
}
204-
).getTaskResult({ taskId }, resultSchema, options);
202+
).getTaskResult({ taskId }, GetTaskPayloadResultSchema, options);
205203
}
206204

207205
/**

packages/client/test/client/streamableHttp.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ describe('StreamableHTTPClientTransport', () => {
122122
expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id');
123123
});
124124

125+
it('should accept protocolVersion constructor option and include it in request headers', async () => {
126+
// When reconnecting with a preserved sessionId, users need to also preserve the
127+
// negotiated protocol version so the required mcp-protocol-version header is sent.
128+
const reconnectTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
129+
sessionId: 'preserved-session-id',
130+
protocolVersion: '2025-11-25'
131+
});
132+
133+
expect(reconnectTransport.sessionId).toBe('preserved-session-id');
134+
expect(reconnectTransport.protocolVersion).toBe('2025-11-25');
135+
136+
(globalThis.fetch as Mock).mockResolvedValueOnce({
137+
ok: true,
138+
status: 202,
139+
headers: new Headers()
140+
});
141+
142+
await reconnectTransport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
143+
144+
const calls = (globalThis.fetch as Mock).mock.calls;
145+
const lastCall = calls.at(-1)!;
146+
expect(lastCall[1].headers.get('mcp-session-id')).toBe('preserved-session-id');
147+
expect(lastCall[1].headers.get('mcp-protocol-version')).toBe('2025-11-25');
148+
149+
await reconnectTransport.close().catch(() => {});
150+
});
151+
125152
it('should terminate session with DELETE request', async () => {
126153
// First, simulate getting a session ID
127154
const message: JSONRPCMessage = {

0 commit comments

Comments
 (0)