Skip to content

Commit 099e11c

Browse files
committed
fix: preserve tool schema metadata across pagination
1 parent 3987ea7 commit 099e11c

6 files changed

Lines changed: 111 additions & 59 deletions

File tree

docs/migration-SKILL.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,8 @@ The 2025-11 task side-channel through `Protocol` is removed (was always `@experi
501501

502502
`TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are also removed; they will return with the SEP-2663 server-directed plugin.
503503

504-
NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task members of the request/result/notification unions, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`.
504+
NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), task
505+
members of the request/result/notification unions, the `tasks` capability key, `isTaskAugmentedRequestParams`, `RELATED_TASK_META_KEY`. Inbound `tasks/*` requests → `-32601`.
505506

506507
## 13. Behavioral Changes
507508

@@ -513,8 +514,10 @@ NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + infe
513514

514515
No code changes required; these are wire-behavior notes:
515516

516-
- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. Behavior for all currently supported protocol versions is unchanged.
517-
- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted — migrated client code should key off the HTTP `404` status, not the `-32001` code.
517+
- Resumability behavior (SSE priming events, `closeSSEStream` / `closeStandaloneSSEStream` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no
518+
longer enable it. Behavior for all currently supported protocol versions is unchanged.
519+
- Session-ID mismatch still responds `404 Not Found` with JSON-RPC error code `-32001` (`Session not found`), unchanged from v1. This `-32001` usage is an SDK convention, not a spec-assigned code, and may be re-derived as 2026 protocol revision error handling is adopted —
520+
migrated client code should key off the HTTP `404` status, not the `-32001` code.
518521

519522
## 14. Runtime-Specific JSON Schema Validators (Enhancement)
520523

@@ -555,7 +558,7 @@ Tool schemas conform to full JSON Schema 2020-12, and `structuredContent` may be
555558
| ---------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
556559
| `inputSchema` root | `type: "object"` + `properties`/`required` only | `type: "object"` required, **plus** any 2020-12 keyword (`oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`$anchor`) |
557560
| `outputSchema` root | `type: "object"` only | **any** valid JSON Schema 2020-12 (object, array, primitive, composition) |
558-
| `Tool.inputSchema` / `Tool.outputSchema` types | object schema with typed `properties`/`required` members | broad JSON Schema records; narrow before reading keyword properties (**source-breaking**) |
561+
| `Tool.inputSchema` / `Tool.outputSchema` types | object schema with typed `properties`/`required` members | broad JSON Schema records; narrow keyword field values before using them (**source-breaking**) |
559562
| `CallToolResult.structuredContent` type | `{ [key: string]: unknown }` | `unknown` (**source-breaking**) |
560563
| `client.callTool(...)` | returns `structuredContent` as object | returns `structuredContent` as `unknown`; narrow it before property access |
561564
| `registerTool` handler return | `structuredContent` untyped | type-checked against the tool's `outputSchema` inferred output |
@@ -570,20 +573,21 @@ const sc = result.structuredContent;
570573
const temp = typeof sc === 'object' && sc !== null && !Array.isArray(sc) ? (sc as Record<string, unknown>).temperature : undefined;
571574
```
572575

573-
Source-breaking fix — property access on `Tool.inputSchema` / `Tool.outputSchema` keyword fields also needs narrowing:
576+
Source-breaking fix — property access on `Tool.inputSchema` / `Tool.outputSchema` keyword field values also needs narrowing:
574577

575578
```typescript
576-
const schema = tool.inputSchema;
577-
const properties =
578-
typeof schema === 'object' && schema !== null && !Array.isArray(schema)
579-
? (schema as Record<string, unknown>).properties
580-
: undefined;
579+
const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
580+
const properties = tool.inputSchema.properties;
581+
if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) {
582+
const propertyNames = Object.keys(properties);
583+
}
581584
```
582585

583586
Behavior notes:
584587

585588
- A server returning array/primitive `structuredContent` automatically also emits a serialized `TextContent` block (old-client interop). No action required.
586-
- Built-in validators reject non-same-document `$ref`/`$dynamicRef` (SSRF) and over-budget schemas (composition DoS). Use a custom `jsonSchemaValidator` to change this.
589+
- Built-in validators reject non-same-document `$ref`/`$dynamicRef` (SSRF) and over-budget schemas (composition DoS). Use `resolveExternalSchemaRefs(schema, { allowlist })` to fetch and inline approved external refs before validation, or use a custom `jsonSchemaValidator` to
590+
change validator behavior.
587591

588592
## 16. Migration Steps (apply in this order)
589593

docs/migration.md

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -336,11 +336,11 @@ Note: the v2 signature takes a plain `string[]` instead of an options object.
336336

337337
### Resumability gating for unknown protocol versions (Streamable HTTP server)
338338

339-
The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an
340-
open-ended `protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior.
339+
The server-side Streamable HTTP transport enables resumability behavior introduced with protocol version `2025-11-25` — SSE priming events and the `closeSSEStream` / `closeStandaloneSSEStream` callbacks — based on the client's protocol version. Previously this was an open-ended
340+
`protocolVersion >= '2025-11-25'` comparison, so an unrecognized future version string in an `initialize` request body (which, unlike the `MCP-Protocol-Version` header, is not validated against the supported-versions list) silently enabled the behavior.
341341

342-
The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through
343-
`2025-11-25`) is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided.
342+
The check is now bounded: the version must be one of the transport's supported protocol versions (after `connect()`, the server's `supportedProtocolVersions`) **and** at least `2025-11-25`. Behavior for all currently supported protocol versions (`2024-10-07` through `2025-11-25`)
343+
is unchanged. Clients claiming an unknown future protocol version in the initialize body are now treated like clients without empty-SSE-data support: no priming event is sent and no early-close callbacks are provided.
344344

345345
### `setRequestHandler` and `setNotificationHandler` use method strings
346346

@@ -902,7 +902,9 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re
902902

903903
**Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will return as part of the SEP-2663 server-directed plugin in a follow-up.
904904

905-
**Wire types remain.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification unions, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and `RELATED_TASK_META_KEY`. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error.
905+
**Wire types remain.** The task wire surface defined by the 2025-11-25 protocol revision is still exported, for interoperability with peers on that revision: the task Zod schemas and their inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`,
906+
`CreateTaskResult`, `GetTask*`, `GetTaskPayload*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification unions, the `tasks` capability key, the `isTaskAugmentedRequestParams` guard, and
907+
`RELATED_TASK_META_KEY`. Only the behavior is gone: servers built on this SDK do not advertise the `tasks` capability, and inbound `tasks/*` requests receive a standard `-32601` (method not found) error.
906908

907909
There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663.
908910

@@ -1005,22 +1007,23 @@ if (typeof sc === 'object' && sc !== null && !Array.isArray(sc)) {
10051007
}
10061008
```
10071009

1008-
The generated `Tool.inputSchema` and `Tool.outputSchema` types also widened to reflect full JSON Schema 2020-12. `Tool.inputSchema.properties`, `Tool.inputSchema.required`, and analogous `outputSchema` fields are no longer statically present. Narrow the schema to an object record
1009-
before reading keyword properties:
1010+
The generated `Tool.inputSchema` and `Tool.outputSchema` types also widened to reflect full JSON Schema 2020-12. Keyword fields such as `properties`, `required`, and analogous `outputSchema` fields now have broad JSON values. Narrow the keyword field value before using it:
10101011

10111012
```typescript
1012-
const schema = tool.inputSchema;
1013-
const properties =
1014-
typeof schema === 'object' && schema !== null && !Array.isArray(schema)
1015-
? (schema as Record<string, unknown>).properties
1016-
: undefined;
1013+
const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
1014+
const properties = tool.inputSchema.properties;
1015+
if (typeof properties === 'object' && properties !== null && !Array.isArray(properties)) {
1016+
const propertyNames = Object.keys(properties);
1017+
}
10171018
```
10181019

10191020
**Stronger server-side typing.** When a tool declares an `outputSchema`, `registerTool` now type-checks the handler's returned `structuredContent` against the schema's inferred output type at compile time — a mismatch is a type error rather than a runtime-only failure.
10201021

1021-
**Old-client interoperability.** A server that returns array or primitive `structuredContent` will automatically also emit a `TextContent` block containing the serialized JSON, so pre-SEP clients that only understand object-typed `structuredContent` can fall back to the text content. Object `structuredContent` (and results that already include a text block) are left unchanged.
1022+
**Old-client interoperability.** A server that returns array or primitive `structuredContent` will automatically also emit a `TextContent` block containing the serialized JSON, so pre-SEP clients that only understand object-typed `structuredContent` can fall back to the text
1023+
content. Object `structuredContent` (and results that already include a text block) are left unchanged.
10221024

1023-
**Security.** The built-in validators never dereference non-same-document `$ref`/`$dynamicRef` (anything not beginning with `#`) — such schemas are rejected rather than fetched, preventing SSRF. Schemas exceeding a generous depth / subschema-count bound are also rejected to prevent composition-based validation DoS. Supply your own `jsonSchemaValidator` implementation if you need different behavior.
1025+
**Security.** The built-in validators never dereference non-same-document `$ref`/`$dynamicRef` (anything not beginning with `#`) — such schemas are rejected rather than fetched, preventing SSRF. If you intentionally need external references, resolve and inline them before
1026+
validation with `resolveExternalSchemaRefs(schema, { allowlist })`. Schemas exceeding a generous depth / subschema-count bound are also rejected to prevent composition-based validation DoS. Supply your own `jsonSchemaValidator` implementation if you need different behavior.
10241027

10251028
## Unchanged APIs
10261029

@@ -1034,9 +1037,9 @@ The following APIs are unchanged between v1 and v2 (only the import paths change
10341037
- All Zod schemas and type definitions from `types.ts` (except the aliases listed above)
10351038
- Tool, prompt, and resource callback return types
10361039

1037-
**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and
1038-
message `Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the
1039-
`-32001` code in client logic; key off the HTTP `404` status instead.
1040+
**Session-ID mismatch responses**: when session management is enabled and a request carries an `Mcp-Session-Id` header that doesn't match the active session, the Streamable HTTP server transport responds `404 Not Found` with a JSON-RPC error body using code `-32001` and message
1041+
`Session not found` — unchanged from v1. Note that this use of `-32001` is an SDK convention, not a spec-assigned error code, and it is expected to be re-derived as error handling for the 2026 protocol revision (`2026-07-28`) is adopted. Avoid hard-coding the `-32001` code in
1042+
client logic; key off the HTTP `404` status instead.
10401043

10411044
## Using an LLM to migrate your code
10421045

packages/client/src/client/client.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -870,11 +870,16 @@ export class Client extends Protocol<ClientContext> {
870870
* Cache validators for tool output schemas.
871871
* Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance.
872872
*/
873-
private cacheToolMetadata(tools: Tool[]): void {
874-
this._cachedToolOutputValidators.clear();
875-
this._toolOutputValidatorErrors.clear();
873+
private cacheToolMetadata(tools: Tool[], reset: boolean): void {
874+
if (reset) {
875+
this._cachedToolOutputValidators.clear();
876+
this._toolOutputValidatorErrors.clear();
877+
}
876878

877879
for (const tool of tools) {
880+
this._cachedToolOutputValidators.delete(tool.name);
881+
this._toolOutputValidatorErrors.delete(tool.name);
882+
878883
// If the tool has an outputSchema, create and cache the validator. Compilation can throw
879884
// (invalid schema, or a SEP-2106 safety-guard rejection); scope that failure to the
880885
// offending tool rather than letting it reject the whole listTools() call.
@@ -926,8 +931,9 @@ export class Client extends Protocol<ClientContext> {
926931
}
927932
const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options);
928933

929-
// Cache the tools and their output schemas for future validation
930-
this.cacheToolMetadata(result.tools);
934+
// Cache the tools and their output schemas for future validation. Preserve entries across
935+
// pagination so validators discovered on earlier pages remain active after the final page.
936+
this.cacheToolMetadata(result.tools, params?.cursor === undefined);
931937

932938
return result;
933939
}

packages/core/src/validators/externalRefResolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ function assertHostAllowed(url: URL, options: ResolvedOptions): void {
142142
const host = url.hostname.toLowerCase();
143143

144144
if (options.allowlist) {
145-
if (!options.allowlist.includes(host)) {
145+
const allowlist = new Set(options.allowlist.map(allowedHost => allowedHost.toLowerCase()));
146+
if (!allowlist.has(host)) {
146147
throw new Error(`Refusing to dereference "${url.href}": host "${host}" is not in the allowlist.`);
147148
}
148149
return;

packages/core/test/validators/externalRefResolver.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ describe('resolveExternalSchemaRefs', () => {
6060
expect(() => assertSchemaSafeToCompile(resolved)).not.toThrow();
6161
});
6262

63+
it('matches allowlist hosts case-insensitively', async () => {
64+
const schema: JsonSchemaType = { $ref: 'https://Schemas.Example.com/forecast.json' };
65+
const fetchImpl = fetchStub({
66+
'https://schemas.example.com/forecast.json': { type: 'array', items: { type: 'number' } }
67+
});
68+
69+
const resolved = await resolveExternalSchemaRefs(schema, { allowlist: ['Schemas.Example.com'], fetch: fetchImpl });
70+
71+
expect(resolved).toEqual({
72+
$ref: '#/$defs/__externalRef_0',
73+
$defs: { __externalRef_0: { type: 'array', items: { type: 'number' } } }
74+
});
75+
});
76+
6377
it.each([
6478
['AJV', () => new AjvJsonSchemaValidator()],
6579
['CfWorker', () => new CfWorkerJsonSchemaValidator()]

0 commit comments

Comments
 (0)