Skip to content

Commit 3d52d12

Browse files
chore: fast-follow nits — SEP-2243 Number() coercion, connect({prior}) docs, icons example (#2364)
1 parent 92b185d commit 3d52d12

7 files changed

Lines changed: 80 additions & 13 deletions

File tree

docs/client.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ Once a modern era is negotiated, the client automatically attaches the per-reque
121121
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,
122122
probe policy, and the `'auto'`-mode compatibility table.
123123

124+
#### Skipping the probe: `connect({ prior })`
125+
126+
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
127+
{@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.
128+
129+
```ts source="../examples/guides/clientGuide.examples.ts#Client_connect_prior"
130+
// Probe once (here via the 'auto'-mode connect), persist the result …
131+
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
132+
await bootstrap.connect(new StreamableHTTPClientTransport(url));
133+
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
134+
135+
// … then every worker connects with zero round trips.
136+
const worker = new Client({ name: 'worker', version: '1.0.0' });
137+
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
138+
```
139+
140+
{@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
141+
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
142+
`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.
143+
124144
### Disconnecting
125145

126146
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.

examples/guides/clientGuide.examples.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ async function Client_versionNegotiation(transport: StreamableHTTPClientTranspor
101101
//#endregion Client_versionNegotiation
102102
}
103103

104+
/** Example: zero-round-trip connect from a persisted DiscoverResult. */
105+
async function Client_connect_prior(url: URL) {
106+
//#region Client_connect_prior
107+
// Probe once (here via the 'auto'-mode connect), persist the result …
108+
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
109+
await bootstrap.connect(new StreamableHTTPClientTransport(url));
110+
const persisted = JSON.stringify(bootstrap.getDiscoverResult());
111+
112+
// … then every worker connects with zero round trips.
113+
const worker = new Client({ name: 'worker', version: '1.0.0' });
114+
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });
115+
//#endregion Client_connect_prior
116+
}
117+
104118
// ---------------------------------------------------------------------------
105119
// Disconnecting
106120
// ---------------------------------------------------------------------------

examples/tools/client.ts

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

2122
const result = await client.callTool({ name: 'calc', arguments: { op: 'add', a: 2, b: 3 } });
2223
check.equal((result.structuredContent as { result?: number } | undefined)?.result, 5);

examples/tools/server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ function buildServer(): McpServer {
2727
b: z.number().describe('right operand')
2828
}),
2929
outputSchema: z.object({ op: z.string(), result: z.number() }),
30-
annotations: { readOnlyHint: true, idempotentHint: true }
30+
annotations: { readOnlyHint: true, idempotentHint: true },
31+
// Icons a client may render in its UI. `src` is required;
32+
// `mimeType`, `sizes`, and `theme` are optional hints.
33+
icons: [{ src: 'https://example.test/calc.svg', mimeType: 'image/svg+xml', sizes: ['any'] }]
3134
},
3235
async ({ op, a, b }) => {
3336
const result = op === 'add' ? a + b : op === 'sub' ? a - b : a * b;

packages/client/src/client/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,8 +2062,8 @@ export class Client extends Protocol<ClientContext> {
20622062
} else if (evicted !== undefined) {
20632063
for (const method of evicted) {
20642064
// `evict()` bumps the generation FIRST and unconditionally
2065-
// (the `_cacheListResult` race guard relies on the bump, not
2066-
// on the store's deletes completing), then drops only THIS
2065+
// (the `ClientResponseCache.write` race guard relies on the
2066+
// bump, not on the store's deletes completing), then drops only THIS
20672067
// server's two partition singletons — co-tenants on a shared
20682068
// store keep their entries. Store failures are reported via
20692069
// `onerror` inside `evict()` and the call resolves, so

packages/core/src/shared/mcpParamHeaders.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ const BASE64_SENTINEL_SUFFIX = '?=';
191191
// RFC 4648 §4, padding required (the spec's encoding-examples table and the
192192
// conformance referee's invalid-padding cell both require canonical padding).
193193
const BASE64_CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
194+
// Strict decimal — gates the numeric comparison in `validateMcpParamHeaders`
195+
// so `Number()` never sees the looser forms it would otherwise accept
196+
// (`'0x1a'`, `' 42 '`, `'1e3'`).
197+
const CANONICAL_DECIMAL = /^-?\d+(\.\d+)?$/;
194198

195199
/**
196200
* Convert a primitive argument value to its string representation per the
@@ -369,17 +373,18 @@ export function validateMcpParamHeaders(
369373
);
370374
}
371375
// Integer/number-typed declarations compare numerically (the spec's
372-
// SHOULD — `42.0` and `42` are equal), but only when both sides parse
373-
// to finite numbers. A non-numeric primitive (e.g. `'abc'` where the
374-
// schema declares `integer`) is a body-vs-schema fault that params
375-
// validation owns; comparing `NaN === NaN` would wrongly report a
376-
// header/body mismatch for an identical pair, so fall back to string
377-
// comparison and let dispatch emit `-32602` instead.
378-
const decodedNum = Number(decoded);
379-
const bodyNum = Number(bodyString);
376+
// SHOULD — `42.0` and `42` are equal). The strict-decimal gate is
377+
// applied to the *header* side only (so `'0x1a'`, `' 42 '`, `'1e3'`
378+
// etc. never coerce); the body side is gated on being an actual JS
379+
// number — `String(0.0000001) === '1e-7'` would fail the regex even
380+
// though the value is perfectly canonical. A non-numeric body
381+
// primitive (e.g. `'abc'` where the schema declares `integer`) is a
382+
// body-vs-schema fault that params validation owns; fall back to
383+
// string comparison and let dispatch emit `-32602` instead so an
384+
// identical non-numeric pair never reports a mismatch.
380385
const numericComparable =
381-
(decl.type === 'integer' || decl.type === 'number') && Number.isFinite(decodedNum) && Number.isFinite(bodyNum);
382-
const equal = numericComparable ? decodedNum === bodyNum : decoded === bodyString;
386+
(decl.type === 'integer' || decl.type === 'number') && CANONICAL_DECIMAL.test(decoded) && typeof bodyRaw === 'number';
387+
const equal = numericComparable ? Number(decoded) === bodyRaw : decoded === bodyString;
383388
if (!equal) {
384389
return paramHeaderMismatchRejection(
385390
'param-header-mismatch',

packages/core/test/shared/mcpParamHeaders.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
302302
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
303303
});
304304

305+
test('number-typed body values that String() in exponent form still compare numerically', () => {
306+
const numDecl = [{ path: ['t'], headerName: 'T', type: 'number' }] as const;
307+
// String(0.0000001) === '1e-7', which is not a canonical decimal — the
308+
// body-side gate is `typeof bodyRaw === 'number'`, NOT the regex, so a
309+
// numerically-equal canonical-decimal header is accepted.
310+
expect(
311+
validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000001' }))
312+
).toBeUndefined();
313+
// And a numerically-different canonical decimal still rejects.
314+
const r = validateMcpParamHeaders(numDecl, { t: 0.0000001 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}T`]: '0.0000002' }));
315+
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
316+
});
317+
318+
test('numeric comparison only engages for canonical decimals (no hex / exponent coercion)', () => {
319+
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
320+
// Each of these would satisfy `Number(header) === 42` but is NOT the
321+
// body's `'42'`; the strict-decimal gate keeps them on the
322+
// string-comparison path so they reject as a mismatch.
323+
for (const loose of ['0x2a', '4.2e1']) {
324+
const r = validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: loose }));
325+
expect(r).toMatchObject({ kind: 'reject', cell: 'param-header-mismatch' });
326+
}
327+
});
328+
305329
test('a non-numeric primitive in a number-declared param falls back to string comparison (no false NaN mismatch)', () => {
306330
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
307331
// Identical header/body — must NOT report a header/body disagreement;

0 commit comments

Comments
 (0)