diff --git a/.changeset/client-ergonomics-batch.md b/.changeset/client-ergonomics-batch.md new file mode 100644 index 000000000..c710d865a --- /dev/null +++ b/.changeset/client-ergonomics-batch.md @@ -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'`). diff --git a/docs/client.md b/docs/client.md index 3b7d208b2..a6bfaabd7 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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 @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index a09412361..6ccd375f3 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -47,7 +47,7 @@ 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 @@ -55,7 +55,7 @@ Failure semantics under `'auto'` are deliberately conservative but never silent 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` diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index c48c0a2b9..53cd397cf 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -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 | diff --git a/examples/gateway/README.md b/examples/gateway/README.md index 55e4a9c11..bf6793077 100644 --- a/examples/gateway/README.md +++ b/examples/gateway/README.md @@ -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. diff --git a/examples/guides/clientGuide.examples.ts b/examples/guides/clientGuide.examples.ts index 0ce10cca0..8dbb6ac77 100644 --- a/examples/guides/clientGuide.examples.ts +++ b/examples/guides/clientGuide.examples.ts @@ -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. diff --git a/examples/oauth/client.ts b/examples/oauth/client.ts index 4da94f4b5..c13df534a 100644 --- a/examples/oauth/client.ts +++ b/examples/oauth/client.ts @@ -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; diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a4d5b14c6..1c66cce35 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -488,6 +488,7 @@ export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; export class UnauthorizedError extends Error { constructor(message?: string) { super(message ?? 'Unauthorized'); + this.name = 'UnauthorizedError'; } } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5028e937c..7465782aa 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -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; + /** * 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 { 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 { 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 { 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 { 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 { 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_))) + ); + } } catch (error) { // Disconnect if initialization fails. void this.close(); @@ -1187,7 +1235,7 @@ export class Client extends Protocol { /** * 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 { @@ -1199,7 +1247,7 @@ export class Client extends Protocol { 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 { * 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 { * ); * ``` */ - async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): Promise { + async listPrompts(params?: ListPromptsRequest['params'], options?: ListRequestOptions): Promise { 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('prompts/list', undefined, options); @@ -1537,13 +1595,13 @@ export class Client extends Protocol { * ); * ``` */ - async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): Promise { + async listResources(params?: ListResourcesRequest['params'], options?: ListRequestOptions): Promise { 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('resources/list', undefined, options); @@ -1567,7 +1625,7 @@ export class Client extends Protocol { */ async listResourceTemplates( params?: ListResourceTemplatesRequest['params'], - options?: CacheableRequestOptions + options?: ListRequestOptions ): Promise { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources @@ -1576,7 +1634,7 @@ export class Client extends Protocol { ); 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('resources/templates/list', undefined, options); @@ -2392,13 +2450,13 @@ export class Client extends Protocol { * ); * ``` */ - async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): Promise { + async listTools(params?: ListToolsRequest['params'], options?: ListRequestOptions): Promise { 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 diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 37c2f6570..837d07b65 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -215,7 +215,7 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): } return { kind: 'error', - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { + error: new SdkError(SdkErrorCode.VersionNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { cause: error }) }; diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index bced56362..d07811c60 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -25,6 +25,7 @@ import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import { UnauthorizedError } from './auth'; import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier'; import { classifyProbeOutcome } from './probeClassifier'; @@ -293,11 +294,6 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { const text = (error.data as { text?: unknown } | undefined)?.text; return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined }; } - if (error instanceof Error && error.name === 'UnauthorizedError') { - // Auth-gated server: not era evidence — the conservative legacy - // fallback re-runs the auth flow through the plain connect path. - return { kind: 'http-error', status: 401 }; - } return { kind: 'network-error', error }; } case 'closed': { @@ -355,6 +351,17 @@ export async function negotiateEra( timeoutMs ); + if (reply.kind === 'send-error' && reply.error instanceof UnauthorizedError) { + // Authorization required: not era evidence and not a negotiation + // failure — surface the same UnauthorizedError the legacy connect + // path throws so callers can finishAuth() and reconnect. (The + // alternative — treating 401 as a legacy-fallback signal — would + // re-run the entire OAuth flow on the initialize send before + // throwing the same error.) Downstream cleanup (window detach, + // transport close) is the existing throw path. + throw reply.error; + } + if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) { timeoutRetriesRemaining--; continue; @@ -385,7 +392,7 @@ export async function negotiateEra( case 'legacy': { if (negotiation.kind === 'pin') { throw new SdkError( - SdkErrorCode.EraNegotiationFailed, + SdkErrorCode.VersionNegotiationFailed, `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + `via server/discover (no fallback in pin mode)` ); @@ -394,7 +401,7 @@ export async function negotiateEra( // Modern-only client: the legacy initialize fallback is // unavailable and must never carry a 2026-era version string. throw new SdkError( - SdkErrorCode.EraNegotiationFailed, + SdkErrorCode.VersionNegotiationFailed, 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + 'pre-2026-07-28 protocol version to fall back to' ); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c9088fc49..f27ba0f29 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -67,7 +67,14 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions'; -export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client'; +export type { + CacheableRequestOptions, + CallToolRequestOptions, + ClientOptions, + ConnectOptions, + ListRequestOptions, + McpSubscription +} from './client/client'; export { Client } from './client/client'; export { getSupportedElicitationModes } from './client/client'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess'; diff --git a/packages/client/test/client/connectPrior.test.ts b/packages/client/test/client/connectPrior.test.ts index c2b443e47..3afe462cf 100644 --- a/packages/client/test/client/connectPrior.test.ts +++ b/packages/client/test/client/connectPrior.test.ts @@ -2,7 +2,7 @@ * `connect({ prior: DiscoverResult })` — zero-round-trip reconnect for the * gateway / distributed-client pattern (issue #79). A previously-obtained * `DiscoverResult` adopted directly: on a modern overlap nothing reaches the - * wire during connect; no modern overlap throws `EraNegotiationFailed` (no + * wire during connect; no modern overlap throws `VersionNegotiationFailed` (no * legacy fallback). Populates `getDiscoverResult()` (alongside the * `'auto'`-mode probe path) and round-trips through JSON. */ @@ -104,13 +104,13 @@ describe('connect({ prior }) — modern overlap: zero round trips', () => { }); describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)', () => { - test('legacy-only prior → SdkError(EraNegotiationFailed) steering to mode: auto', async () => { + test('legacy-only prior → SdkError(VersionNegotiationFailed) steering to mode: auto', async () => { const transport = new ScriptedTransport(); const client = new Client({ name: 'worker', version: '0' }); await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy( error => error instanceof SdkError && - error.code === SdkErrorCode.EraNegotiationFailed && + error.code === SdkErrorCode.VersionNegotiationFailed && /2026-07-28\+ mutual/.test(error.message) && /mode: 'auto'/.test(error.message) ); @@ -119,11 +119,11 @@ describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)' expect(client.getDiscoverResult()).toBeUndefined(); }); - test('disjoint modern lists → SdkError(EraNegotiationFailed)', async () => { + test('disjoint modern lists → SdkError(VersionNegotiationFailed)', async () => { const transport = new ScriptedTransport(); const client = new Client({ name: 'worker', version: '0' }); await expect(client.connect(transport, { prior: prior(['2099-01-01']) })).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); expect(transport.sent).toHaveLength(0); }); diff --git a/packages/client/test/client/discover.test.ts b/packages/client/test/client/discover.test.ts index af5c4ab31..c46470e9c 100644 --- a/packages/client/test/client/discover.test.ts +++ b/packages/client/test/client/discover.test.ts @@ -84,6 +84,43 @@ describe('Client.discover()', () => { await client.close(); }); + test('absent ttlMs/cacheScope default to 0/private (spec receiver-side SHOULD — caching §TTL); not the resultType posture', async () => { + // The 2026-07-28 spec marks ttlMs/cacheScope REQUIRED on the SENDER side + // ("Servers MUST provide a ttlMs value that is >= 0") but tells receivers: + // "If ttlMs is absent, clients SHOULD assume a default of 0". The SDK + // applies the receiver-side SHOULD: a non-conformant server's discover + // result is accepted with safe defaults, unlike a missing resultType + // (ResultProtocolMismatch). The sender obligation is enforced by + // the SDK server's encode step, not by parse here. + const lenientScript = (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isJSONRPCRequest(message)) return; + if (message.method === 'server/discover') { + t.reply({ + jsonrpc: '2.0', + id: message.id, + result: { + // ttlMs, cacheScope and resultType deliberately omitted. + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'nonconformant-server', version: '1.0.0' } + } + }); + } + }; + const transport = new ScriptedTransport(lenientScript); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + + // The connect-time probe classifier parses with the wire schema, which + // applies the spec receiver-side SHOULD defaults — so connect succeeds + // and getDiscoverResult() reports the defaulted values. + const advertisement = client.getDiscoverResult(); + expect(advertisement?.ttlMs).toBe(0); + expect(advertisement?.cacheScope).toBe('private'); + + await client.close(); + }); + test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => { const transport = new ScriptedTransport(legacyScript); const client = new Client({ name: 'c', version: '0' }); diff --git a/packages/client/test/client/envelopeAutoEmission.test.ts b/packages/client/test/client/envelopeAutoEmission.test.ts index 0ab3deecd..9fbefa84b 100644 --- a/packages/client/test/client/envelopeAutoEmission.test.ts +++ b/packages/client/test/client/envelopeAutoEmission.test.ts @@ -12,6 +12,7 @@ import { CLIENT_INFO_META_KEY, InMemoryTransport, LATEST_PROTOCOL_VERSION, + LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; import { describe, expect, it } from 'vitest'; @@ -32,7 +33,7 @@ function metaOf(message: JSONRPCMessage): Record | undefined { * negotiating client lands on the modern era) or `initialize` (legacy era), and * records everything the client writes. */ -async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = true) { +async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = true, withLogging = false) { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); const written: JSONRPCMessage[] = []; serverTx.onmessage = message => { @@ -46,7 +47,7 @@ async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = tr result: { resultType: 'complete', supportedVersions: [MODERN], - capabilities: { tools: {} }, + capabilities: { tools: {}, ...(withLogging && { logging: {} }) }, serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } } }); @@ -61,12 +62,16 @@ async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = tr id: request.id, result: { protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { tools: {} }, + capabilities: { tools: {}, ...(withLogging && { logging: {} }) }, serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' } } }); return; } + if (request.method === 'logging/setLevel' && request.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', id: request.id, result: {} }); + return; + } if (request.method === 'tools/list' && request.id !== undefined && answerToolsList) { const result: Record = era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] }; @@ -230,6 +235,79 @@ describe('setVersionNegotiation()', () => { }); }); +describe('ClientOptions.logLevel', () => { + it('attaches the LOG_LEVEL_META_KEY envelope key to every outgoing request on a modern connection', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client( + { name: 'envelope-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, logLevel: 'info' } + ); + await client.connect(clientTx); + + await client.listTools(); + await flush(); + + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[LOG_LEVEL_META_KEY]).toBe('info'); + + await client.close(); + }); + + it('a per-call _meta[LOG_LEVEL_META_KEY] overrides the option default', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client( + { name: 'envelope-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, logLevel: 'info' } + ); + await client.connect(clientTx); + + await client.request({ method: 'tools/list', params: { _meta: { [LOG_LEVEL_META_KEY]: 'debug' } } }); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[LOG_LEVEL_META_KEY]).toBe('debug'); + + await client.close(); + }); + + it('without the option set, no LOG_LEVEL_META_KEY is attached (unchanged default)', async () => { + const { clientTx, written } = await scriptedServerSide('modern'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(clientTx); + + await client.listTools(); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + expect(metaOf(listToolsMessage!)?.[LOG_LEVEL_META_KEY]).toBeUndefined(); + + await client.close(); + }); + + it('on a legacy connection: no envelope key, but a single best-effort logging/setLevel is sent when the server advertises logging', async () => { + const { clientTx, written } = await scriptedServerSide('legacy', true, /* withLogging */ true); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { logLevel: 'warning' }); + await client.connect(clientTx); + await flush(); + + const setLevel = written.find(m => (m as { method?: string }).method === 'logging/setLevel'); + expect(setLevel).toBeDefined(); + expect((setLevel as { params?: { level?: string } }).params?.level).toBe('warning'); + + await client.listTools(); + const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list'); + // The 2025 codec emits no envelope at all, so the key never rides legacy traffic. + expect(metaOf(listToolsMessage!)?.[LOG_LEVEL_META_KEY]).toBeUndefined(); + + await client.close(); + }); + + it('on a legacy connection without the logging capability, no logging/setLevel is sent', async () => { + const { clientTx, written } = await scriptedServerSide('legacy'); + const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { logLevel: 'info' }); + await client.connect(clientTx); + await flush(); + expect(written.some(m => (m as { method?: string }).method === 'logging/setLevel')).toBe(false); + await client.close(); + }); +}); + describe('getProtocolEra()', () => { it('is undefined before connect, "legacy" after a 2025 handshake, "modern" after a 2026-07-28 negotiation', async () => { const legacy = await scriptedServerSide('legacy'); diff --git a/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts index 12fe9e408..6957a027e 100644 --- a/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts +++ b/packages/client/test/client/legacyHandshakeModernOnlyGuard.test.ts @@ -39,7 +39,7 @@ describe('plain client with a modern-only supported-versions list', () => { const client = new Client({ name: 'modern-only-client', version: '1.0.0' }, { supportedProtocolVersions }); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); expect(transport.sent.filter(message => 'method' in message && message.method === 'initialize')).toHaveLength(0); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 74d0c3248..ecc9cc6ef 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -262,7 +262,7 @@ describe('row: network outage → typed connect error (Node)', () => { expect(verdict.kind).toBe('error'); if (verdict.kind === 'error') { expect(verdict.error).toBeInstanceOf(SdkError); - expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.VersionNegotiationFailed); } }); diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 5006f383c..e201cb13f 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -419,11 +419,18 @@ describe('Client response-cache substrate', () => { expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); expect(listCount()).toBe(1); + // allPages: false → first page only, raw nextCursor preserved, NO cache write. + const firstPage = await client.listTools(undefined, { allPages: false }); + expect(firstPage.tools.map(t => t.name)).toEqual(['a']); + expect(firstPage.nextCursor).toBe('1'); + expect(store.get({ method: 'tools/list', partition: part() })).toBeUndefined(); + expect(listCount()).toBe(2); + // No cursor → aggregates every page and writes one entry. const { tools, nextCursor } = await client.listTools(); expect(tools.map(t => t.name)).toEqual(['a', 'b']); expect(nextCursor).toBeUndefined(); - expect(listCount()).toBe(3); + expect(listCount()).toBe(4); const entry = store.get({ method: 'tools/list', partition: part() }); expect((entry?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 7a41347ae..921e46b73 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -18,6 +18,7 @@ import { } from '@modelcontextprotocol/core-internal'; import { describe, expect, test } from 'vitest'; +import { UnauthorizedError } from '../../src/client/auth'; import { Client } from '../../src/client/client'; import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp'; import type { StdioServerParameters } from '../../src/client/stdio'; @@ -312,7 +313,7 @@ describe('auto mode against a legacy server (fallback)', () => { ); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); // The fallback never ran: no initialize carrying any version was sent. expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); @@ -393,7 +394,7 @@ describe('probe timeout policy (transport-aware)', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN }, probe: { timeoutMs: 30 } } }); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); @@ -582,7 +583,7 @@ describe('pin mode', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false); }); @@ -607,7 +608,7 @@ describe('pin mode', () => { const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); // The probe window's one-shot start() pass-through must not stay armed @@ -706,3 +707,62 @@ describe('era scope discipline', () => { expect(noCachedEra).toBe(true); }); }); + +/* ------------------------------------------------------------------------- * + * Connect-time 401 from the auth provider: short-circuit rethrow. + * ------------------------------------------------------------------------- */ + +describe("connect-time UnauthorizedError under 'auto' / pin", () => { + /** A transport whose `send()` rejects every call with the given error (emulates a 401 from the auth layer at probe time). */ + class RejectingTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + sent: JSONRPCMessage[] = []; + closed = false; + constructor(private readonly error: unknown) {} + async start(): Promise {} + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + throw this.error; + } + async close(): Promise { + this.closed = true; + this.onclose?.(); + } + } + + test("'auto': UnauthorizedError from the probe send rejects connect() directly — no VersionNegotiationFailed wrapping, no initialize", async () => { + const transport = new RejectingTransport(new UnauthorizedError()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(UnauthorizedError); + expect((rejection as Error).name).toBe('UnauthorizedError'); + // Exactly one probe was attempted; the legacy initialize fallback never ran. + expect(transport.sent).toHaveLength(1); + expect((transport.sent[0] as JSONRPCRequest).method).toBe('server/discover'); + // _connectNegotiated's catch closes the transport on a typed connect error. + expect(transport.closed).toBe(true); + }); + + test('pin mode: UnauthorizedError is rethrown directly (not a pin-miss)', async () => { + const transport = new RejectingTransport(new UnauthorizedError('redirect required')); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await expect(client.connect(transport)).rejects.toBeInstanceOf(UnauthorizedError); + }); + + test('a non-auth network failure on the probe send still rejects with SdkError(VersionNegotiationFailed)', async () => { + const transport = new RejectingTransport(new Error('ECONNREFUSED')); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.VersionNegotiationFailed); + }); +}); diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index b808d9887..15c70b3cc 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -28,6 +28,16 @@ export enum SdkErrorCode { SendFailed = 'SEND_FAILED', /** Response result failed local schema validation */ InvalidResult = 'INVALID_RESULT', + /** + * The peer returned a result whose shape does not match the negotiated + * protocol version's schema — e.g. a 2026-07-28 peer omitted (or + * malformed) the REQUIRED `resultType` discriminator. Distinct from + * {@linkcode InvalidResult} (a result that fails the per-method schema) + * so downstream tooling can classify "the peer is non-conformant for the + * negotiated revision" separately from a malformed payload. `data.method` + * carries the method, `data.violation` the rule. + */ + ResultProtocolMismatch = 'RESULT_PROTOCOL_MISMATCH', /** * The response carried a `resultType` discriminator (protocol revision * 2026-07-28) naming a result kind this client cannot consume yet, e.g. @@ -61,14 +71,16 @@ export enum SdkErrorCode { */ MethodNotSupportedByProtocolVersion = 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', /** - * Protocol-era negotiation at connect time failed without producing either a - * usable modern (2026-07-28+) era or a definitive legacy fallback signal — - * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a - * network failure (a typed connect error, never an era verdict). + * Protocol version negotiation at connect time failed without producing + * either a usable modern (2026-07-28+) revision or a definitive legacy + * fallback signal — e.g. the negotiation mode forbids falling back + * (`pin`), or the probe hit a network failure (a typed connect error, + * never a verdict). * - * Negotiation-phase only: this code is never used once an era is established. + * Negotiation-phase only: this code is never used once a protocol version + * is established. */ - EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED', + VersionNegotiationFailed = 'VERSION_NEGOTIATION_FAILED', // Transport errors ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', diff --git a/packages/core-internal/src/wire/rev2026-07-28/codec.ts b/packages/core-internal/src/wire/rev2026-07-28/codec.ts index d76d41024..10fda92af 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/codec.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/codec.ts @@ -187,7 +187,7 @@ export const rev2026Codec: WireCodec & { return { kind: 'invalid', error: new SdkError( - SdkErrorCode.InvalidResult, + SdkErrorCode.ResultProtocolMismatch, `Invalid result for ${method}: missing required resultType — servers implementing protocol revision 2026-07-28 ` + `MUST include it (the absent-means-complete bridge applies only to earlier-revision servers)`, { method, violation: 'missing-resultType' } @@ -197,8 +197,9 @@ export const rev2026Codec: WireCodec & { if (typeof rawResultType !== 'string') { return { kind: 'invalid', - error: new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${method}: non-string resultType`, { + error: new SdkError(SdkErrorCode.ResultProtocolMismatch, `Invalid result for ${method}: non-string resultType`, { method, + violation: 'non-string-resultType', resultType: rawResultType }) }; diff --git a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts index 3e44c48ed..d9dcead7f 100644 --- a/packages/core-internal/test/shared/rawResultTypeFirst.test.ts +++ b/packages/core-internal/test/shared/rawResultTypeFirst.test.ts @@ -104,7 +104,7 @@ describe('raw-first resultType discrimination — 2026 era (codec decode step 1) expect('rejected' in outcome).toBe(true); const rejection = (outcome as { rejected: unknown }).rejected as SdkError; expect(rejection).toBeInstanceOf(SdkError); - expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.code).toBe(SdkErrorCode.ResultProtocolMismatch); expect(rejection.message).toContain('missing required resultType'); expect(rejection.data).toMatchObject({ method: 'tools/call', violation: 'missing-resultType' }); @@ -118,7 +118,7 @@ describe('raw-first resultType discrimination — 2026 era (codec decode step 1) expect('rejected' in outcome).toBe(true); const rejection = (outcome as { rejected: unknown }).rejected as SdkError; expect(rejection).toBeInstanceOf(SdkError); - expect(rejection.code).toBe(SdkErrorCode.InvalidResult); + expect(rejection.code).toBe(SdkErrorCode.ResultProtocolMismatch); expect(rejection.data).toMatchObject({ resultType: 42 }); await protocol.close(); diff --git a/packages/core-internal/test/types/errorSurfacePins.test.ts b/packages/core-internal/test/types/errorSurfacePins.test.ts index 59a207791..8917519c3 100644 --- a/packages/core-internal/test/types/errorSurfacePins.test.ts +++ b/packages/core-internal/test/types/errorSurfacePins.test.ts @@ -75,11 +75,12 @@ describe('SdkErrorCode', () => { ConnectionClosed: 'CONNECTION_CLOSED', SendFailed: 'SEND_FAILED', InvalidResult: 'INVALID_RESULT', + ResultProtocolMismatch: 'RESULT_PROTOCOL_MISMATCH', UnsupportedResultType: 'UNSUPPORTED_RESULT_TYPE', InputRequiredRoundsExceeded: 'INPUT_REQUIRED_ROUNDS_EXCEEDED', ListPaginationExceeded: 'LIST_PAGINATION_EXCEEDED', MethodNotSupportedByProtocolVersion: 'METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION', - EraNegotiationFailed: 'ERA_NEGOTIATION_FAILED', + VersionNegotiationFailed: 'VERSION_NEGOTIATION_FAILED', ClientHttpNotImplemented: 'CLIENT_HTTP_NOT_IMPLEMENTED', ClientHttpAuthentication: 'CLIENT_HTTP_AUTHENTICATION', ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 99480ae3f..5415526a8 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -175,7 +175,7 @@ export const REQUIREMENTS: Record = { 'typescript:client:connect:prior-zero-roundtrip': { source: 'sdk', behavior: - 'connect(transport, { prior: DiscoverResult }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. prior is modern-only — no modern overlap throws SdkError(EraNegotiationFailed) (no legacy fallback).', + 'connect(transport, { prior: DiscoverResult }) against a 2026-07-28 server is zero-round-trip: a fresh client supplied with a previously-obtained DiscoverResult connects without putting any HTTP exchange on the wire, adopts the modern era directly, and callTool round-trips immediately. prior is modern-only — no modern overlap throws SdkError(VersionNegotiationFailed) (no legacy fallback).', transports: ['entryModern'], addedInSpecVersion: '2026-07-28', note: 'Runs on the entryModern arm; the wired (negotiating) client is the bootstrap that obtains the DiscoverResult, then a fresh worker client connects to the same harness-hosted endpoint via wired.url + a fresh StreamableHTTPClientTransport over wired.fetch with { prior }. The zero-round-trip clause is asserted on the arm-recorded httpLog length.' diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index eb42e73f3..b4970dff7 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -172,7 +172,7 @@ verifies('typescript:client:raw-result-type-first', async ({ transport }: TestAr const rejection = (outcome as { rejected: unknown }).rejected; expect(rejection).toBeInstanceOf(SdkError); const typed = rejection as SdkError; - expect(typed.code).toBe(SdkErrorCode.InvalidResult); + expect(typed.code).toBe(SdkErrorCode.ResultProtocolMismatch); expect(String(typed.message)).toContain('missing required resultType'); } finally { await client.close(); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index 2bd93eba9..7e0ed763f 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -193,7 +193,7 @@ describe('typed connect errors (Q12) over real sockets', () => { const transport = new StreamableHTTPClientTransport(url); await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + error => error instanceof SdkError && error.code === SdkErrorCode.VersionNegotiationFailed ); });