Skip to content
Closed
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
12 changes: 12 additions & 0 deletions .changeset/client-ergonomics-batch.md
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'`).
4 changes: 2 additions & 2 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse
```

{@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
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
`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
Expand Down Expand Up @@ -298,7 +298,7 @@ try {
return client;
} catch (error) {
// With version negotiation, the connect-time 401 may surface wrapped as
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
// SdkError(VersionNegotiationFailed) whose .data.cause is the UnauthorizedError.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
// The transport called redirectToAuthorization(); fall through to the browser callback.
Expand Down
4 changes: 2 additions & 2 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ client.getProtocolEra(); // 'modern' | 'legacy'
- **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on
the same connection against a 2025-only server (one extra round trip).
- **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with
`SdkError(EraNegotiationFailed)` against a 2025-only server.
`SdkError(VersionNegotiationFailed)` against a 2025-only server.

#### Probe policy

Failure semantics under `'auto'` are deliberately conservative but never silent about
infrastructure problems. Anything the probe does not positively recognize as modern
falls back to the legacy era — provided the supported-versions list still contains a
2025-era revision; with a modern-only list `connect()` rejects with
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
`SdkError(VersionNegotiationFailed)` instead. A network outage rejects with a typed connect
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
on the same stream (some legacy servers never respond to unknown pre-`initialize`
Expand Down
3 changes: 2 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,11 +493,12 @@ if (error instanceof SdkHttpError) {
| `ConnectionClosed` | Connection was closed |
| `SendFailed` | Failed to send message |
| `InvalidResult` | Response result failed local schema validation |
| `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) |
| `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` |
| `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` |
| `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` |
| `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version |
| `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) |
| `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 |
| `ClientHttpNotImplemented` | HTTP POST request failed |
| `ClientHttpAuthentication` | Server returned 401 after re-authentication |
| `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap |
Expand Down
2 changes: 1 addition & 1 deletion examples/gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ The server exposes a `request_count` tool returning how many MCP requests reache
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
client-side capability gating.

`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(EraNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
`connect({ prior })` is **modern-only**: no mutual 2026-07-28+ revision → `SdkError(VersionNegotiationFailed)`. Use `versionNegotiation: { mode: 'auto' }` for legacy-era fallback.
2 changes: 1 addition & 1 deletion examples/guides/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ async function auth_finishAuth(url: URL, provider: OAuthClientProvider & { lastS
return client;
} catch (error) {
// With version negotiation, the connect-time 401 may surface wrapped as
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
// SdkError(VersionNegotiationFailed) whose .data.cause is the UnauthorizedError.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
// The transport called redirectToAuthorization(); fall through to the browser callback.
Expand Down
2 changes: 1 addition & 1 deletion examples/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ try {
} catch (error) {
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
// and wraps it in an VersionNegotiationFailed `SdkError` whose `data.cause`
// is the original `UnauthorizedError`. Either way the auth driver has
// already run by the time we land here — DCR done, auth URL captured.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
Comment on lines 115 to 121

Copy link
Copy Markdown

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 a VersionNegotiationFailed SdkError whose data.cause carries the original UnauthorizedError — but this same PR removes that wrapping (negotiateEra() now rethrows the UnauthorizedError directly in every negotiation mode). Suggest simplifying the comment + catch to the plain if (!(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 renames EraNegotiationFailed to VersionNegotiationFailed while leaving the surrounding claim intact — that under mode: 'auto' "the version-negotiation probe is what got 401'd and wraps it in an VersionNegotiationFailed SdkError whose data.cause is the original UnauthorizedError". That is exactly the behavior this PR removes. Note this is a different file from the earlier review comment, which covered docs/client.md and examples/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() in packages/client/src/client/versionNegotiation.ts (~lines 354–363) now short-circuits on reply.kind === 'send-error' && reply.error instanceof UnauthorizedError and rethrows the UnauthorizedError itself, and the old error.name === 'UnauthorizedError' row in normalizeReply (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 throw UnauthorizedError directly in every negotiation mode".

Step-by-step proof

  1. The example connects with versionNegotiation: { mode: 'auto' } and a StreamableHTTPClientTransport whose authProvider rejects the probe send with UnauthorizedError (the 401 path the example exists to exercise).
  2. With this PR, negotiateEra() hits the new short-circuit and rethrows the bare UnauthorizedError; connect() rejects with it directly.
  3. In the example's catch, error instanceof UnauthorizedError matches first, so root is the error itself — the (error as { data?: { cause?: unknown } }).data?.cause fallback 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 instanceof branch 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?.cause unwrapping 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:

} catch (error) {
    // Either way (--legacy or mode: 'auto') the transport's auth driver has
    // already run by the time we land here — DCR done, auth URL captured —
    // and connect() rejects with the UnauthorizedError directly.
    if (!(error instanceof UnauthorizedError)) throw error;
    challenged = true;
}

This drops the now-false wrapping claim, removes the dead data?.cause unwrapping, and incidentally fixes the "an VersionNegotiationFailed" grammar introduced by the rename.

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ export type AuthResult = 'AUTHORIZED' | 'REDIRECT';
export class UnauthorizedError extends Error {
constructor(message?: string) {
super(message ?? 'Unauthorized');
this.name = 'UnauthorizedError';
}
}

Expand Down
84 changes: 71 additions & 13 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 ClientOptions.logLevel, which makes the Client auto-attach the io.modelcontextprotocol/logLevel _meta envelope key on every outgoing request on a 2026-07-28 connection (and send a single best-effort logging/setLevel after a 2025-era handshake). However, docs/migration/support-2026-07-28.md — the canonical doc for the 2026-07-28 feature area — still states in its logging section (~lines 166-171): "The SDK Client does not auto-attach logLevel, so handler logs on a default 2026-era exchange are silently suppressed until the client opts in." That prose now describes only the pre-PR state and omits the very SDK-level opt-in this PR ships.\n\nThe code path that contradicts it. packages/client/src/client/client.ts now stores options?.logLevel (_logLevel, ~line 539/622) and passes it into outboundEnvelope() (~line 692). The 2026 codec (packages/core-internal/src/wire/rev2026-07-28/codec.ts, outboundEnvelope) stamps LOG_LEVEL_META_KEY whenever material.logLevel !== undefined. The new tests in envelopeAutoEmission.test.ts ("attaches the LOG_LEVEL_META_KEY envelope key to every outgoing request on a modern connection") confirm the auto-attach behavior.\n\nWhy existing docs don't cover it. The PR updated docs/migration/upgrade-to-v2.md (only the SdkErrorCode table and EraNegotiationFailed wording) and the changeset, but no prose documentation was added for the new option, and the 2026 migration doc — the natural place a reader would look for "how do I receive request-tied server logs on a 2026 connection" — was not touched. Its comparison table (~line 441) likewise only mentions the per-request _meta key path.\n\nStep-by-step reader impact. 1) A user upgrades to a 2026-07-28 server and notices their handler's ctx.mcpReq.log() output never arrives. 2) They open support-2026-07-28.md and find the section titled "ctx.mcpReq.log() and the per-request logLevel". 3) The doc tells them the SDK Client does not auto-attach logLevel and that the only opt-in is per-call params._meta['io.modelcontextprotocol/logLevel']. 4) They hand-roll the per-call _meta attachment on every request — exactly the boilerplate this PR's ClientOptions.logLevel was added to eliminate — never discovering the option exists.\n\nWhy this is a nit rather than a hard contradiction. The default behavior is unchanged (no option set ⇒ no key attached), so the sentence is stale/incomplete rather than flat wrong, and "until the client opts in" could charitably be read as covering the new option. Nothing breaks at runtime. Still, the repo's review conventions ask to flag docs/**/*.md prose that now describes old behavior and to verify new features get prose documentation, not just JSDoc.\n\nHow to fix. Amend the sentence to something like: "The SDK Client does not attach logLevel by default; set ClientOptions.logLevel to have the Client auto-attach the io.modelcontextprotocol/logLevel envelope key on every request (a per-call _meta value still overrides it)." Optionally add a row/mention in the comparison table at ~line 441 pointing at ClientOptions.logLevel.

/**
* Multi-round-trip auto-fulfilment (protocol revision 2026-07-28).
*
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
});
}

Expand Down Expand Up @@ -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'
);
}
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The fire-and-forget logging/setLevel sent here is not awaited, so if the caller closes the client (or the transport drops) before the response arrives, the pending request is rejected with SdkError(ConnectionClosed) and the .catch routes it to onerror — the consumer sees a spurious error event for a connection they closed cleanly. Consider filtering SdkErrorCode.ConnectionClosed (and arguably RequestTimeout) in the catch, consistent with the best-effort intent stated in the comment above.

Extended reasoning...

What the bug is. The legacy half of ClientOptions.logLevel fires void this.setLoggingLevel(this._logLevel).catch(error_ => this.onerror?.(...)) at the end of _legacyHandshake, deliberately not awaited so a failure cannot fail connect(). Because the request outlives connect(), it is still in flight when the consumer's first close() (or a transport drop) lands — and at that point the SDK reports an error to onerror for a teardown the user initiated cleanly.

The code path. setLoggingLevel() goes through Protocol.request(), which registers a response handler keyed by request id. Protocol.close() / transport close runs _onclose() (packages/core-internal/src/shared/protocol.ts:798-807), which settles every outstanding response handler with SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'). That rejection propagates into the new .catch at packages/client/src/client/client.ts:1076-1078, which forwards it unconditionally to this.onerror.

Step-by-step proof. (1) new Client(info, { logLevel: 'info' }) connects to a 2025-era server that advertises logging in its capabilities. (2) _legacyHandshake completes the initialize exchange, sees this._logLevel !== undefined && this._serverCapabilities?.logging, and fires logging/setLevel without awaiting; connect() resolves immediately. (3) The caller does one quick operation (or none) and calls client.close() — a perfectly normal one-shot CLI / health-probe pattern — before the logging/setLevel response round-trips. (4) close() triggers Protocol._onclose(), which rejects the pending setLoggingLevel promise with SdkError(ConnectionClosed). (5) The .catch calls this.onerror?.(error), so the consumer's error handler fires (logging noise, alerting, etc.) for a connection they shut down deliberately and successfully.

Why existing code doesn't prevent it. The inline comment explicitly addresses one half of this class of problem: the connect-time signal/onprogress are not propagated because "a post-resolve abort would surface as a spurious onerror." But the post-resolve close race produces exactly the same spurious onerror, and nothing filters it — the catch forwards every rejection. Note that the existing modern-era auto-listen path (around client.ts:1212-1228) is awaited inside connect(), so it does not have this outlives-connect race; this fire-and-forget request is the only one the client issues.

Impact. Limited to noise: connect() still succeeds, close() still completes, and no state is corrupted. But onerror is the consumer's error-reporting hook, so SDK-initiated traffic intermittently emitting errors on clean shutdown will look like a real failure (and is timing-dependent, so it shows up flakily). The same applies to a RequestTimeout if a server that advertised logging simply never answers setLevel — also not actionable for the consumer given the request is best-effort by design.

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();
Expand Down Expand Up @@ -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> {
Expand All @@ -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."
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading