Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example by felixweinberger · Pull Request #2364 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the [2026-07-28 support guide](./migration/support-2026-07-28.md#serving-the-2026-07-28-revision) for the full failure semantics,
probe policy, and the `'auto'`-mode compatibility table.

#### Skipping the probe: `connect({ prior })`

A gateway, proxy, or worker fleet that already knows the server's `server/discover` advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via
{@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and `connect()` adopts it directly with **zero round trips** — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
```

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the `'auto'`/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(EraNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
`DiscoverResult` across clients that present the **same authorization context** as the one that obtained it. See the [`gateway/` example](../examples/gateway/README.md) for the full probe-once / connect-many pattern with a server-side proof.

### Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.
Expand Down
14 changes: 14 additions & 0 deletions examples/guides/clientGuide.examples.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
//#endregion Client_versionNegotiation
}

/** Example: zero-round-trip connect from a persisted DiscoverResult. */
async function Client_connect_prior(url: URL) {
//#region Client_connect_prior
// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
//#endregion Client_connect_prior
}

// ---------------------------------------------------------------------------
// Disconnecting
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions examples/tools/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ runClient('tools', async () => {
const required = (calc.inputSchema as { required?: string[] }).required ?? [];
check.ok(required.includes('op') && required.includes('a') && required.includes('b'));
check.ok(calc.outputSchema, 'calc should publish an outputSchema');
check.equal(calc.icons?.[0]?.src, 'https://example.test/calc.svg', 'calc should advertise its icons over the wire');

const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);
Expand Down
5 changes: 4 additions & 1 deletion examples/tools/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ function buildServer(): McpServer {
b: z.number().describe('right operand')
}),
outputSchema: z.object({ op: z.string(), result: z.number() }),
annotations: { readOnlyHint: true, idempotentHint: true }
annotations: { readOnlyHint: true, idempotentHint: true },
// Icons a client may render in its UI. `src` is required;
// `mimeType`, `sizes`, and `theme` are optional hints.
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
},
async ({ op, a, b }) => {
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
} else if (evicted !== undefined) {
for (const method of evicted) {
// `evict()` bumps the generation FIRST and unconditionally
// (the `_cacheListResult` race guard relies on the bump, not
// on the store's deletes completing), then drops only THIS
// (the `ClientResponseCache.write` race guard relies on the
// bump, not on the store's deletes completing), then drops only THIS
// server's two partition singletons — co-tenants on a shared
// store keep their entries. Store failures are reported via
// `onerror` inside `evict()` and the call resolves, so
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/shared/mcpParamHeaders.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
// conformance referee's invalid-padding cell both require canonical padding).
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
// so `Number()` never sees the looser forms it would otherwise accept
// (`'0x1a'`, `' 42 '`, `'1e3'`).
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;

/**
* Convert a primitive argument value to its string representation per the
Expand DownExpand Up@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
);
}
// Integer/number-typed declarations compare numerically (the spec's
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
// schema declares `integer`) is a body-vs-schema fault that params
// validation owns; comparing `NaN === NaN` would wrongly report a
// header/body mismatch for an identical pair, so fall back to string
// comparison and let dispatch emit `-32602` instead.
const decodedNum = Number(decoded);
const bodyNum = Number(bodyString);
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
// etc. never coerce); the body side is gated on being an actual JS
// number — `String(0.0000001) === '1e-7'` would fail the regex even
// though the value is perfectly canonical. A non-numeric body
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
// body-vs-schema fault that params validation owns; fall back to
// string comparison and let dispatch emit `-32602` instead so an
// identical non-numeric pair never reports a mismatch.
const numericComparable =
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
if (!equal) {
return paramHeaderMismatchRejection(
'param-header-mismatch',
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/shared/mcpParamHeaders.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
});

test('number-typed body values that String() in exponent form still compare numerically', () => {
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
// numerically-equal canonical-decimal header is accepted.
expect(
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
).toBeUndefined();
// And a numerically-different canonical decimal still rejects.
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
});

test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Each of these would satisfy `Number(header) === 42` but is NOT the
// body's `'42'`; the strict-decimal gate keeps them on the
// string-comparison path so they reject as a mismatch.
for (const loose of ['0x2a', '4.2e1']) {
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
}
});

test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
// Identical header/body — must NOT report a header/body disagreement;
Expand Down
Loading