Skip to content

Commit 2dcacb5

Browse files
feat(client): ergonomics batch — UnauthorizedError under auto, logLevel, list page-1, typed era-validation error (#102,#126-128,#168,#169)
1 parent 1823aae commit 2dcacb5

26 files changed

Lines changed: 341 additions & 59 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@modelcontextprotocol/core-internal": minor
3+
"@modelcontextprotocol/client": minor
4+
---
5+
6+
Client ergonomics batch:
7+
8+
- `connect()` under `versionNegotiation: 'auto'` / `{ pin }` now rejects with `UnauthorizedError` directly when the auth provider rejects the connect-time probe (previously wrapped in `SdkError(VersionNegotiationFailed).data.cause`). `UnauthorizedError` now sets `error.name`.
9+
- New `ClientOptions.logLevel`: auto-attaches the `io.modelcontextprotocol/logLevel` `_meta` envelope key on 2026-07-28 connections, and sends a single best-effort `logging/setLevel` after a 2025-era handshake when the server advertises `logging`.
10+
- New `ListRequestOptions.allPages`: pass `{ allPages: false }` to `listTools()` / `listPrompts()` / `listResources()` / `listResourceTemplates()` to fetch only the first page (with its raw `nextCursor`) instead of auto-aggregating.
11+
- New `SdkErrorCode.ResultProtocolMismatch`: a 2026-07-28 peer result that omits or malforms the REQUIRED `resultType` discriminator now rejects with this code (was `InvalidResult`), so tooling can classify a non-conformant peer separately from a malformed payload.
12+
- **Breaking (alpha-only):** `SdkErrorCode.EraNegotiationFailed` is renamed to `SdkErrorCode.VersionNegotiationFailed` (string value `'VERSION_NEGOTIATION_FAILED'`).

docs/client.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse
137137
```
138138

139139
{@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
140-
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
140+
prior `connect({ prior })` recorded; it round-trips through `JSON.stringify`/`JSON.parse`. `connect({ prior })` is **2026-07-28+ only** — it rejects with `SdkError(VersionNegotiationFailed)` when the supplied result and the client share no modern revision. Only reuse a persisted
141141
`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.
142142

143143
### Disconnecting
@@ -298,7 +298,7 @@ try {
298298
return client;
299299
} catch (error) {
300300
// With version negotiation, the connect-time 401 may surface wrapped as
301-
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
301+
// SdkError(VersionNegotiationFailed) whose .data.cause is the UnauthorizedError.
302302
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
303303
if (!(root instanceof UnauthorizedError)) throw error;
304304
// The transport called redirectToAuthorization(); fall through to the browser callback.

docs/migration/support-2026-07-28.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ client.getProtocolEra(); // 'modern' | 'legacy'
4747
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on
4848
the same connection against a 2025-only server (one extra round trip).
4949
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
50-
`SdkError(EraNegotiationFailed)` against a 2025-only server.
50+
`SdkError(VersionNegotiationFailed)` against a 2025-only server.
5151

5252
#### Probe policy
5353

5454
Failure semantics under `'auto'` are deliberately conservative but never silent about
5555
infrastructure problems. Anything the probe does not positively recognize as modern
5656
falls back to the legacy era — provided the supported-versions list still contains a
5757
2025-era revision; with a modern-only list `connect()` rejects with
58-
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
58+
`SdkError(VersionNegotiationFailed)` instead. A network outage rejects with a typed connect
5959
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
6060
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
6161
on the same stream (some legacy servers never respond to unknown pre-`initialize`

docs/migration/upgrade-to-v2.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,11 +493,12 @@ if (error instanceof SdkHttpError) {
493493
| `ConnectionClosed` | Connection was closed |
494494
| `SendFailed` | Failed to send message |
495495
| `InvalidResult` | Response result failed local schema validation |
496+
| `ResultProtocolMismatch` | The peer returned a result whose shape does not match the negotiated protocol version's schema (e.g. a 2026-07-28 peer omitted the REQUIRED `resultType` discriminator) |
496497
| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` |
497498
| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` |
498499
| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` |
499500
| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version |
500-
| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) |
501+
| `VersionNegotiationFailed` | `connect()` could not negotiate a protocol version (probe failed / no overlap). Auth-required connects throw `UnauthorizedError` directly in every negotiation mode — `VersionNegotiationFailed` is reserved for genuine negotiation failures |
501502
| `ClientHttpNotImplemented` | HTTP POST request failed |
502503
| `ClientHttpAuthentication` | Server returned 401 after re-authentication |
503504
| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap |

examples/gateway/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
4747
Only reuse a persisted `DiscoverResult` across workers that present the **same authorization context** as the bootstrap client (key the blob on a credential hash). Adopting a wider `prior` does not grant access — the server authorizes every request — but it can mislead
4848
client-side capability gating.
4949

50-
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
50+
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(VersionNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.

examples/guides/clientGuide.examples.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastS
294294
return client;
295295
} catch (error) {
296296
// With version negotiation, the connect-time 401 may surface wrapped as
297-
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
297+
// SdkError(VersionNegotiationFailed) whose .data.cause is the UnauthorizedError.
298298
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
299299
if (!(root instanceof UnauthorizedError)) throw error;
300300
// The transport called redirectToAuthorization(); fall through to the browser callback.

examples/oauth/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ try {
115115
} catch (error) {
116116
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
117117
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
118-
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
118+
// and wraps it in an VersionNegotiationFailed `SdkError` whose `data.cause`
119119
// is the original `UnauthorizedError`. Either way the auth driver has
120120
// already run by the time we land here — DCR done, auth URL captured.
121121
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;

packages/client/src/client/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
488488
export class UnauthorizedError extends Error {
489489
constructor(message?: string) {
490490
super(message ?? 'Unauthorized');
491+
this.name = 'UnauthorizedError';
491492
}
492493
}
493494

packages/client/src/client/client.ts

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,24 @@ export type ClientOptions = ProtocolOptions & {
205205
*/
206206
versionNegotiation?: VersionNegotiationOptions;
207207

208+
/**
209+
* Per-request log opt-in. On 2026-07-28 connections the level is attached
210+
* to every outgoing request as the `io.modelcontextprotocol/logLevel`
211+
* `_meta` envelope key (without it, servers MUST NOT emit
212+
* `notifications/message` for the request). On 2025-era connections the
213+
* client sends a single best-effort `logging/setLevel` after the handshake
214+
* when the server advertises the `logging` capability. A per-call
215+
* `params._meta[LOG_LEVEL_META_KEY]` still overrides this default.
216+
*
217+
* The envelope key rides every outgoing message (notifications included);
218+
* it is inert on messages that are not request-scoped logs.
219+
*
220+
* Note: MCP-level logging is in the SEP-2577 deprecation window; this
221+
* option fronts that vocabulary deliberately because it is currently the
222+
* only way to receive request-tied server logs on 2026-07-28 connections.
223+
*/
224+
logLevel?: LoggingLevel;
225+
208226
/**
209227
* Multi-round-trip auto-fulfilment (protocol revision 2026-07-28).
210228
*
@@ -329,7 +347,7 @@ export type ConnectOptions = RequestOptions & {
329347
* A previously-obtained {@linkcode DiscoverResult} (see
330348
* {@linkcode Client.getDiscoverResult}). When supplied, `connect()` adopts
331349
* it directly — zero round trips. 2026-07-28+ only: throws
332-
* `SdkError(EraNegotiationFailed)` when there is no modern overlap. Only
350+
* `SdkError(VersionNegotiationFailed)` when there is no modern overlap. Only
333351
* reuse across clients presenting the same authorization context.
334352
*/
335353
prior?: DiscoverResult;
@@ -351,6 +369,21 @@ export type CacheableRequestOptions = RequestOptions & {
351369
cacheMode?: CacheMode;
352370
};
353371

372+
/**
373+
* {@linkcode CacheableRequestOptions} extended with a per-call opt-out of the
374+
* list verbs' auto-aggregating walk.
375+
*/
376+
export type ListRequestOptions = CacheableRequestOptions & {
377+
/**
378+
* Set to `false` to fetch only the first page (with its raw `nextCursor`)
379+
* instead of walking and aggregating every page. Equivalent to passing an
380+
* explicit `{ cursor }` for page 1, which the typed verbs cannot otherwise
381+
* express because page 1 has no cursor. The single-page path does not
382+
* consult or write the response cache. Default: `true` (auto-aggregate).
383+
*/
384+
allPages?: boolean;
385+
};
386+
354387
/**
355388
* Options for {@linkcode Client.callTool}. Extends {@linkcode RequestOptions}
356389
* with an escape hatch for callers that already hold the tool definition
@@ -503,6 +536,7 @@ export class Client extends Protocol<ClientContext> {
503536
private readonly _listChangedConfig?: ListChangedHandlers;
504537
private _enforceStrictCapabilities: boolean;
505538
private _versionNegotiation?: VersionNegotiationOptions;
539+
private readonly _logLevel?: LoggingLevel;
506540
private _supportedProtocolVersionsOption?: string[];
507541
private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig;
508542
/**
@@ -585,6 +619,7 @@ export class Client extends Protocol<ClientContext> {
585619
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
586620
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
587621
this._versionNegotiation = options?.versionNegotiation;
622+
this._logLevel = options?.logLevel;
588623
this._supportedProtocolVersionsOption = options?.supportedProtocolVersions;
589624
// Multi-round-trip auto-fulfilment driver (2026-07-28): on by default,
590625
// configurable via ClientOptions.inputRequired.
@@ -653,7 +688,8 @@ export class Client extends Protocol<ClientContext> {
653688
return this._wireCodec().outboundEnvelope({
654689
protocolVersion: version,
655690
clientInfo: this._clientInfo,
656-
clientCapabilities: this._capabilities
691+
clientCapabilities: this._capabilities,
692+
logLevel: this._logLevel
657693
});
658694
}
659695

@@ -978,7 +1014,7 @@ export class Client extends Protocol<ClientContext> {
9781014
const offeredVersion = legacyVersions[0];
9791015
if (offeredVersion === undefined) {
9801016
throw new SdkError(
981-
SdkErrorCode.EraNegotiationFailed,
1017+
SdkErrorCode.VersionNegotiationFailed,
9821018
'Cannot run the initialize handshake: supportedProtocolVersions contains no pre-2026-07-28 protocol version'
9831019
);
9841020
}
@@ -1029,6 +1065,18 @@ export class Client extends Protocol<ClientContext> {
10291065
if (this._listChangedConfig) {
10301066
this._setupListChangedHandlers(this._listChangedConfig);
10311067
}
1068+
1069+
// Legacy half of ClientOptions.logLevel: a single best-effort
1070+
// logging/setLevel after the handshake when the server advertises
1071+
// logging. A failure here must not fail connect(). The connect-time
1072+
// signal/onprogress are deliberately NOT propagated — this request
1073+
// outlives connect() and a post-resolve abort would surface as a
1074+
// spurious onerror.
1075+
if (this._logLevel !== undefined && this._serverCapabilities?.logging) {
1076+
void this.setLoggingLevel(this._logLevel).catch(error_ =>
1077+
this.onerror?.(error_ instanceof Error ? error_ : new Error(String(error_)))
1078+
);
1079+
}
10321080
} catch (error) {
10331081
// Disconnect if initialization fails.
10341082
void this.close();
@@ -1187,7 +1235,7 @@ export class Client extends Protocol<ClientContext> {
11871235

11881236
/**
11891237
* Connect from a previously-obtained {@linkcode DiscoverResult}. Always
1190-
* zero-round-trip; throws `EraNegotiationFailed` when there is no
1238+
* zero-round-trip; throws `VersionNegotiationFailed` when there is no
11911239
* 2026-07-28+ overlap (no legacy fallback). See {@linkcode ConnectOptions}.
11921240
*/
11931241
private async _connectFromPrior(transport: Transport, prior: DiscoverResult): Promise<void> {
@@ -1199,7 +1247,7 @@ export class Client extends Protocol<ClientContext> {
11991247
const version = clientModern.find(v => prior.supportedVersions.includes(v));
12001248
if (version === undefined) {
12011249
throw new SdkError(
1202-
SdkErrorCode.EraNegotiationFailed,
1250+
SdkErrorCode.VersionNegotiationFailed,
12031251
"connect({ prior }) requires a 2026-07-28+ mutual protocol version; the supplied DiscoverResult and this client's " +
12041252
"supportedProtocolVersions have no modern overlap. Use versionNegotiation: { mode: 'auto' } for legacy-era fallback."
12051253
);
@@ -1286,6 +1334,16 @@ export class Client extends Protocol<ClientContext> {
12861334
* The {@linkcode DiscoverResult} from the last `'auto'`/pinned probe,
12871335
* {@linkcode discover} call, or `connect({ prior })`. Persistable via
12881336
* `JSON.stringify`; feed to {@linkcode ConnectOptions} `prior`.
1337+
*
1338+
* Note on receiver-side leniency: per the 2026-07-28 spec
1339+
* (caching §TTL — "If `ttlMs` is absent, clients SHOULD assume a default
1340+
* of `0`"; "If `ttlMs` is negative, clients SHOULD ignore it and treat it
1341+
* as `0`"), the SDK's discover decoder defaults absent or malformed
1342+
* `ttlMs` / `cacheScope` to `0` / `'private'` rather than rejecting. This
1343+
* is deliberately less strict than the `resultType` posture (which has no
1344+
* such receiver-side SHOULD and rejects when missing). The sender
1345+
* obligation — `ttlMs >= 0` and `cacheScope` MUST be present — is still
1346+
* REQUIRED on the wire and is enforced by the SDK server's encode step.
12891347
*/
12901348
getDiscoverResult(): DiscoverResult | undefined {
12911349
return this._discoverResult;
@@ -1497,13 +1555,13 @@ export class Client extends Protocol<ClientContext> {
14971555
* );
14981556
* ```
14991557
*/
1500-
async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): Promise<ListPromptsResult> {
1558+
async listPrompts(params?: ListPromptsRequest['params'], options?: ListRequestOptions): Promise<ListPromptsResult> {
15011559
if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) {
15021560
// Respect capability negotiation: server does not support prompts
15031561
console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
15041562
return { prompts: [] };
15051563
}
1506-
if (params?.cursor !== undefined) {
1564+
if (params?.cursor !== undefined || options?.allPages === false) {
15071565
return this.request({ method: 'prompts/list', params }, options);
15081566
}
15091567
const hit = await this._serveFromCache<ListPromptsResult>('prompts/list', undefined, options);
@@ -1537,13 +1595,13 @@ export class Client extends Protocol<ClientContext> {
15371595
* );
15381596
* ```
15391597
*/
1540-
async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): Promise<ListResourcesResult> {
1598+
async listResources(params?: ListResourcesRequest['params'], options?: ListRequestOptions): Promise<ListResourcesResult> {
15411599
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
15421600
// Respect capability negotiation: server does not support resources
15431601
console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list');
15441602
return { resources: [] };
15451603
}
1546-
if (params?.cursor !== undefined) {
1604+
if (params?.cursor !== undefined || options?.allPages === false) {
15471605
return this.request({ method: 'resources/list', params }, options);
15481606
}
15491607
const hit = await this._serveFromCache<ListResourcesResult>('resources/list', undefined, options);
@@ -1567,7 +1625,7 @@ export class Client extends Protocol<ClientContext> {
15671625
*/
15681626
async listResourceTemplates(
15691627
params?: ListResourceTemplatesRequest['params'],
1570-
options?: CacheableRequestOptions
1628+
options?: ListRequestOptions
15711629
): Promise<ListResourceTemplatesResult> {
15721630
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
15731631
// Respect capability negotiation: server does not support resources
@@ -1576,7 +1634,7 @@ export class Client extends Protocol<ClientContext> {
15761634
);
15771635
return { resourceTemplates: [] };
15781636
}
1579-
if (params?.cursor !== undefined) {
1637+
if (params?.cursor !== undefined || options?.allPages === false) {
15801638
return this.request({ method: 'resources/templates/list', params }, options);
15811639
}
15821640
const hit = await this._serveFromCache<ListResourceTemplatesResult>('resources/templates/list', undefined, options);
@@ -2392,13 +2450,13 @@ export class Client extends Protocol<ClientContext> {
23922450
* );
23932451
* ```
23942452
*/
2395-
async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): Promise<ListToolsResult> {
2453+
async listTools(params?: ListToolsRequest['params'], options?: ListRequestOptions): Promise<ListToolsResult> {
23962454
if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) {
23972455
// Respect capability negotiation: server does not support tools
23982456
console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list');
23992457
return { tools: [] };
24002458
}
2401-
if (params?.cursor !== undefined) {
2459+
if (params?.cursor !== undefined || options?.allPages === false) {
24022460
// Per-page: single request, never written to the response cache.
24032461
// SEP-2243: the spec's MUST has no carve-out for paginated reads,
24042462
// so the per-page result is filtered (on a non-stdio modern

0 commit comments

Comments
 (0)