-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(client): UnauthorizedError under auto, ClientOptions.logLevel, list page-1 opt-out, typed era-validation error #2377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| "@modelcontextprotocol/core-internal": minor | ||
| "@modelcontextprotocol/client": minor | ||
| --- | ||
|
|
||
| Client ergonomics batch: | ||
|
|
||
| - `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`. | ||
| - 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`. | ||
| - 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. | ||
| - 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. | ||
| - **Breaking (alpha-only):** `SdkErrorCode.EraNegotiationFailed` is renamed to `SdkErrorCode.VersionNegotiationFailed` (string value `'VERSION_NEGOTIATION_FAILED'`). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -205,6 +205,24 @@ export type ClientOptions = ProtocolOptions & { | |
| */ | ||
| versionNegotiation?: VersionNegotiationOptions; | ||
|
|
||
| /** | ||
| * Per-request log opt-in. On 2026-07-28 connections the level is attached | ||
| * to every outgoing request as the `io.modelcontextprotocol/logLevel` | ||
| * `_meta` envelope key (without it, servers MUST NOT emit | ||
| * `notifications/message` for the request). On 2025-era connections the | ||
| * client sends a single best-effort `logging/setLevel` after the handshake | ||
| * when the server advertises the `logging` capability. A per-call | ||
| * `params._meta[LOG_LEVEL_META_KEY]` still overrides this default. | ||
| * | ||
| * The envelope key rides every outgoing message (notifications included); | ||
| * it is inert on messages that are not request-scoped logs. | ||
| * | ||
| * Note: MCP-level logging is in the SEP-2577 deprecation window; this | ||
| * option fronts that vocabulary deliberately because it is currently the | ||
| * only way to receive request-tied server logs on 2026-07-28 connections. | ||
| */ | ||
| logLevel?: LoggingLevel; | ||
|
|
||
|
Comment on lines
+208
to
+225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 docs/migration/support-2026-07-28.md (section "ctx.mcpReq.log() and the per-request logLevel", ~lines 166-171) still says "The SDK Client does not auto-attach logLevel", but with this PR the Client does auto-attach the io.modelcontextprotocol/logLevel envelope key when ClientOptions.logLevel is set. Please amend that sentence (and the comparison table around line 441) to mention ClientOptions.logLevel as the SDK-level opt-in (default still off) — the PR updated upgrade-to-v2.md and the changeset but not this doc. Extended reasoning...What the bug is. This PR introduces |
||
| /** | ||
| * Multi-round-trip auto-fulfilment (protocol revision 2026-07-28). | ||
| * | ||
|
|
@@ -329,7 +347,7 @@ export type ConnectOptions = RequestOptions & { | |
| * A previously-obtained {@linkcode DiscoverResult} (see | ||
| * {@linkcode Client.getDiscoverResult}). When supplied, `connect()` adopts | ||
| * it directly — zero round trips. 2026-07-28+ only: throws | ||
| * `SdkError(EraNegotiationFailed)` when there is no modern overlap. Only | ||
| * `SdkError(VersionNegotiationFailed)` when there is no modern overlap. Only | ||
| * reuse across clients presenting the same authorization context. | ||
| */ | ||
| prior?: DiscoverResult; | ||
|
|
@@ -351,6 +369,21 @@ export type CacheableRequestOptions = RequestOptions & { | |
| cacheMode?: CacheMode; | ||
| }; | ||
|
|
||
| /** | ||
| * {@linkcode CacheableRequestOptions} extended with a per-call opt-out of the | ||
| * list verbs' auto-aggregating walk. | ||
| */ | ||
| export type ListRequestOptions = CacheableRequestOptions & { | ||
| /** | ||
| * Set to `false` to fetch only the first page (with its raw `nextCursor`) | ||
| * instead of walking and aggregating every page. Equivalent to passing an | ||
| * explicit `{ cursor }` for page 1, which the typed verbs cannot otherwise | ||
| * express because page 1 has no cursor. The single-page path does not | ||
| * consult or write the response cache. Default: `true` (auto-aggregate). | ||
| */ | ||
| allPages?: boolean; | ||
| }; | ||
|
|
||
| /** | ||
| * Options for {@linkcode Client.callTool}. Extends {@linkcode RequestOptions} | ||
| * with an escape hatch for callers that already hold the tool definition | ||
|
|
@@ -503,6 +536,7 @@ export class Client extends Protocol<ClientContext> { | |
| private readonly _listChangedConfig?: ListChangedHandlers; | ||
| private _enforceStrictCapabilities: boolean; | ||
| private _versionNegotiation?: VersionNegotiationOptions; | ||
| private readonly _logLevel?: LoggingLevel; | ||
| private _supportedProtocolVersionsOption?: string[]; | ||
| private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; | ||
| /** | ||
|
|
@@ -585,6 +619,7 @@ export class Client extends Protocol<ClientContext> { | |
| this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); | ||
| this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; | ||
| this._versionNegotiation = options?.versionNegotiation; | ||
| this._logLevel = options?.logLevel; | ||
| this._supportedProtocolVersionsOption = options?.supportedProtocolVersions; | ||
| // Multi-round-trip auto-fulfilment driver (2026-07-28): on by default, | ||
| // configurable via ClientOptions.inputRequired. | ||
|
|
@@ -653,7 +688,8 @@ export class Client extends Protocol<ClientContext> { | |
| return this._wireCodec().outboundEnvelope({ | ||
| protocolVersion: version, | ||
| clientInfo: this._clientInfo, | ||
| clientCapabilities: this._capabilities | ||
| clientCapabilities: this._capabilities, | ||
| logLevel: this._logLevel | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -978,7 +1014,7 @@ export class Client extends Protocol<ClientContext> { | |
| const offeredVersion = legacyVersions[0]; | ||
| if (offeredVersion === undefined) { | ||
| throw new SdkError( | ||
| SdkErrorCode.EraNegotiationFailed, | ||
| SdkErrorCode.VersionNegotiationFailed, | ||
| 'Cannot run the initialize handshake: supportedProtocolVersions contains no pre-2026-07-28 protocol version' | ||
| ); | ||
| } | ||
|
|
@@ -1029,6 +1065,18 @@ export class Client extends Protocol<ClientContext> { | |
| if (this._listChangedConfig) { | ||
| this._setupListChangedHandlers(this._listChangedConfig); | ||
| } | ||
|
|
||
| // Legacy half of ClientOptions.logLevel: a single best-effort | ||
| // logging/setLevel after the handshake when the server advertises | ||
| // logging. A failure here must not fail connect(). The connect-time | ||
| // signal/onprogress are deliberately NOT propagated — this request | ||
| // outlives connect() and a post-resolve abort would surface as a | ||
| // spurious onerror. | ||
| if (this._logLevel !== undefined && this._serverCapabilities?.logging) { | ||
| void this.setLoggingLevel(this._logLevel).catch(error_ => | ||
| this.onerror?.(error_ instanceof Error ? error_ : new Error(String(error_))) | ||
| ); | ||
| } | ||
|
Comment on lines
+1069
to
+1079
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The fire-and-forget Extended reasoning...What the bug is. The legacy half of The code path. Step-by-step proof. (1) Why existing code doesn't prevent it. The inline comment explicitly addresses one half of this class of problem: the connect-time Impact. Limited to noise: How to fix. Swallow the expected best-effort failure modes in the catch, e.g.: void this.setLoggingLevel(this._logLevel).catch(error_ => {
if (error_ instanceof SdkError && (error_.code === SdkErrorCode.ConnectionClosed || error_.code === SdkErrorCode.RequestTimeout)) {
return; // best-effort: the connection was closed (or the server never answered) before the response arrived
}
this.onerror?.(error_ instanceof Error ? error_ : new Error(String(error_)));
});Alternatively, track the pending promise and skip reporting once the client has been closed. Either keeps genuine failures (e.g. a protocol error from the server) visible while aligning the implementation with the stated best-effort intent. |
||
| } catch (error) { | ||
| // Disconnect if initialization fails. | ||
| void this.close(); | ||
|
|
@@ -1187,7 +1235,7 @@ export class Client extends Protocol<ClientContext> { | |
|
|
||
| /** | ||
| * Connect from a previously-obtained {@linkcode DiscoverResult}. Always | ||
| * zero-round-trip; throws `EraNegotiationFailed` when there is no | ||
| * zero-round-trip; throws `VersionNegotiationFailed` when there is no | ||
| * 2026-07-28+ overlap (no legacy fallback). See {@linkcode ConnectOptions}. | ||
| */ | ||
| private async _connectFromPrior(transport: Transport, prior: DiscoverResult): Promise<void> { | ||
|
|
@@ -1199,7 +1247,7 @@ export class Client extends Protocol<ClientContext> { | |
| const version = clientModern.find(v => prior.supportedVersions.includes(v)); | ||
| if (version === undefined) { | ||
| throw new SdkError( | ||
| SdkErrorCode.EraNegotiationFailed, | ||
| SdkErrorCode.VersionNegotiationFailed, | ||
| "connect({ prior }) requires a 2026-07-28+ mutual protocol version; the supplied DiscoverResult and this client's " + | ||
| "supportedProtocolVersions have no modern overlap. Use versionNegotiation: { mode: 'auto' } for legacy-era fallback." | ||
| ); | ||
|
|
@@ -1286,6 +1334,16 @@ export class Client extends Protocol<ClientContext> { | |
| * The {@linkcode DiscoverResult} from the last `'auto'`/pinned probe, | ||
| * {@linkcode discover} call, or `connect({ prior })`. Persistable via | ||
| * `JSON.stringify`; feed to {@linkcode ConnectOptions} `prior`. | ||
| * | ||
| * Note on receiver-side leniency: per the 2026-07-28 spec | ||
| * (caching §TTL — "If `ttlMs` is absent, clients SHOULD assume a default | ||
| * of `0`"; "If `ttlMs` is negative, clients SHOULD ignore it and treat it | ||
| * as `0`"), the SDK's discover decoder defaults absent or malformed | ||
| * `ttlMs` / `cacheScope` to `0` / `'private'` rather than rejecting. This | ||
| * is deliberately less strict than the `resultType` posture (which has no | ||
| * such receiver-side SHOULD and rejects when missing). The sender | ||
| * obligation — `ttlMs >= 0` and `cacheScope` MUST be present — is still | ||
| * REQUIRED on the wire and is enforced by the SDK server's encode step. | ||
| */ | ||
| getDiscoverResult(): DiscoverResult | undefined { | ||
| return this._discoverResult; | ||
|
|
@@ -1497,13 +1555,13 @@ export class Client extends Protocol<ClientContext> { | |
| * ); | ||
| * ``` | ||
| */ | ||
| async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): Promise<ListPromptsResult> { | ||
| async listPrompts(params?: ListPromptsRequest['params'], options?: ListRequestOptions): Promise<ListPromptsResult> { | ||
| if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { | ||
| // Respect capability negotiation: server does not support prompts | ||
| console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); | ||
| return { prompts: [] }; | ||
| } | ||
| if (params?.cursor !== undefined) { | ||
| if (params?.cursor !== undefined || options?.allPages === false) { | ||
| return this.request({ method: 'prompts/list', params }, options); | ||
| } | ||
| const hit = await this._serveFromCache<ListPromptsResult>('prompts/list', undefined, options); | ||
|
|
@@ -1537,13 +1595,13 @@ export class Client extends Protocol<ClientContext> { | |
| * ); | ||
| * ``` | ||
| */ | ||
| async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): Promise<ListResourcesResult> { | ||
| async listResources(params?: ListResourcesRequest['params'], options?: ListRequestOptions): Promise<ListResourcesResult> { | ||
| if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { | ||
| // Respect capability negotiation: server does not support resources | ||
| console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); | ||
| return { resources: [] }; | ||
| } | ||
| if (params?.cursor !== undefined) { | ||
| if (params?.cursor !== undefined || options?.allPages === false) { | ||
| return this.request({ method: 'resources/list', params }, options); | ||
| } | ||
| const hit = await this._serveFromCache<ListResourcesResult>('resources/list', undefined, options); | ||
|
|
@@ -1567,7 +1625,7 @@ export class Client extends Protocol<ClientContext> { | |
| */ | ||
| async listResourceTemplates( | ||
| params?: ListResourceTemplatesRequest['params'], | ||
| options?: CacheableRequestOptions | ||
| options?: ListRequestOptions | ||
| ): Promise<ListResourceTemplatesResult> { | ||
| if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { | ||
| // Respect capability negotiation: server does not support resources | ||
|
|
@@ -1576,7 +1634,7 @@ export class Client extends Protocol<ClientContext> { | |
| ); | ||
| return { resourceTemplates: [] }; | ||
| } | ||
| if (params?.cursor !== undefined) { | ||
| if (params?.cursor !== undefined || options?.allPages === false) { | ||
| return this.request({ method: 'resources/templates/list', params }, options); | ||
| } | ||
| const hit = await this._serveFromCache<ListResourceTemplatesResult>('resources/templates/list', undefined, options); | ||
|
|
@@ -2392,13 +2450,13 @@ export class Client extends Protocol<ClientContext> { | |
| * ); | ||
| * ``` | ||
| */ | ||
| async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): Promise<ListToolsResult> { | ||
| async listTools(params?: ListToolsRequest['params'], options?: ListRequestOptions): Promise<ListToolsResult> { | ||
| if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { | ||
| // Respect capability negotiation: server does not support tools | ||
| console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); | ||
| return { tools: [] }; | ||
| } | ||
| if (params?.cursor !== undefined) { | ||
| if (params?.cursor !== undefined || options?.allPages === false) { | ||
| // Per-page: single request, never written to the response cache. | ||
| // SEP-2243: the spec's MUST has no carve-out for paginated reads, | ||
| // so the per-page result is filtered (on a non-stdio modern | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The comment edited by this PR (the EraNegotiationFailed → VersionNegotiationFailed rename on this line) still says that under
mode: 'auto'the connect-time 401 is wrapped in aVersionNegotiationFailedSdkError whosedata.causecarries the originalUnauthorizedError— but this same PR removes that wrapping (negotiateEra()now rethrows theUnauthorizedErrordirectly in every negotiation mode). Suggest simplifying the comment + catch to the plainif (!(error instanceof UnauthorizedError)) throw error;pattern the PR description showcases (which also fixes the "an VersionNegotiationFailed" grammar).Extended reasoning...
What is stale
This PR edits the comment in
examples/oauth/client.ts(lines ~115–121), but only mechanically: it renamesEraNegotiationFailedtoVersionNegotiationFailedwhile leaving the surrounding claim intact — that undermode: 'auto'"the version-negotiation probe is what got 401'd and wraps it in an VersionNegotiationFailedSdkErrorwhosedata.causeis the originalUnauthorizedError". That is exactly the behavior this PR removes. Note this is a different file from the earlier review comment, which covereddocs/client.mdandexamples/guides/clientGuide.examples.ts(files the PR does not touch); this site is actively edited by the diff.Why the claim is no longer true
negotiateEra()inpackages/client/src/client/versionNegotiation.ts(~lines 354–363) now short-circuits onreply.kind === 'send-error' && reply.error instanceof UnauthorizedErrorand rethrows theUnauthorizedErroritself, and the olderror.name === 'UnauthorizedError'row innormalizeReply(the path that previously fed the wrapping/fallback) is deleted in this same diff. The new tests pin it: "'auto': UnauthorizedError from the probe send rejects connect() directly — no VersionNegotiationFailed wrapping". The migration table edited in this diff says the same: "Auth-required connects throwUnauthorizedErrordirectly in every negotiation mode".Step-by-step proof
versionNegotiation: { mode: 'auto' }and aStreamableHTTPClientTransportwhoseauthProviderrejects the probe send withUnauthorizedError(the 401 path the example exists to exercise).negotiateEra()hits the new short-circuit and rethrows the bareUnauthorizedError;connect()rejects with it directly.error instanceof UnauthorizedErrormatches first, sorootis the error itself — the(error as { data?: { cause?: unknown } }).data?.causefallback on the next line is now dead code under'auto', and the comment describes a wrapped shape the SDK never produces after this PR.Why nothing flags it
The example still runs and passes its checks (the
instanceofbranch handles the new direct throw), so no test or type-check catches the stale prose. Impact is purely misleading documentation: the file is one of the OAuth reference examples, and it teaches the.data?.causeunwrapping dance this PR's headline change exists to eliminate, contradicting the migration-table wording added in the same diff.How to fix
Simplify the catch to the pattern shown in the PR description:
This drops the now-false wrapping claim, removes the dead
data?.causeunwrapping, and incidentally fixes the "an VersionNegotiationFailed" grammar introduced by the rename.