diff --git a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx index 697c4d13f7..3a2bd5432d 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx @@ -92,7 +92,7 @@ interface ServerDetailModalProps { * Undefined = no host-level pin = "Legacy · default" attribution on * the chip. */ - hostDefaultMcpProtocolVersion?: McpProtocolVersion; + hostDefaultMcpProtocolVersion?: McpProtocolVersion | "auto"; /** Project default XAA test identity — shown as override placeholders. */ projectXaaDefaultIdentity?: { subject: string; email: string } | null; } @@ -176,8 +176,12 @@ export function ServerDetailModal({ // without forcing the Servers tab to also wire up the provider just // for the chip's source attribution. const activeMcpProfile = useActiveMcpProfile(); - const resolvedHostDefaultMcpProtocolVersion: McpProtocolVersion | undefined = + const storedHostDefaultMcpProtocolVersion = hostDefaultMcpProtocolVersion ?? activeMcpProfile?.mcpProtocolVersion; + const resolvedHostDefaultMcpProtocolVersion: McpProtocolVersion | undefined = + storedHostDefaultMcpProtocolVersion === "auto" + ? undefined + : storedHostDefaultMcpProtocolVersion; const canEditMcpProtocolVersionOverride = Boolean( canQueryProjectServerConfig && serverId && diff --git a/mcpjam-inspector/client/src/components/hosts/redesigned/focus/ProtocolTab.tsx b/mcpjam-inspector/client/src/components/hosts/redesigned/focus/ProtocolTab.tsx index a59753fbab..638d04ad5e 100644 --- a/mcpjam-inspector/client/src/components/hosts/redesigned/focus/ProtocolTab.tsx +++ b/mcpjam-inspector/client/src/components/hosts/redesigned/focus/ProtocolTab.tsx @@ -42,10 +42,9 @@ import type { HostAttentionIssue } from "../types"; import { useJsonDraftBuffer } from "./useJsonDraftBuffer"; /** - * "auto" is the UI-only sentinel for "no pin stored" — it maps to - * `mcpProfile.mcpProtocolVersion === undefined`, NOT to a wire literal. - * Deliberately not labelled with a version number: absence means the SDK - * picks the version at connect time, so hardcoding a revision into that + * "auto" is a stored selection policy, NOT a wire literal. The SDK negotiates + * at connect time and never emits the string itself. Deliberately not labelled + * with a version number: hardcoding a revision into that * label would go stale the moment the SDK's default moves (the sequenced * Phase-5 `versionNegotiation: 'auto'` activation) without anything in * this file changing. @@ -124,7 +123,7 @@ const HOST_PROTOCOL_OPTIONS: Array<{ /** * Which versions this client may actually be pinned to. * - * The backend refuses to store a STATEFUL pin the client does not also + * The backend refuses to store a concrete pin the client does not also * advertise in `initialize.supportedProtocolVersions` — the SDK's * `ConflictingProtocolVersionPin` rule in `canonicalizeMcpProfile`. Presets * carry that list (VS Code ships `["2025-11-25"]`), so offering every version @@ -132,15 +131,12 @@ const HOST_PROTOCOL_OPTIONS: Array<{ * opaque "Server Error". Offer what actually saves instead. * * The advertised list is the whole answer, INCLUDING for stateless revisions. - * The backend only validates stateful pins (stateless ones skip the initialize - * handshake, so `ConflictingProtocolVersionPin` never fires for them), but - * "the backend would accept it" is not the same as "this client speaks it": - * offering `2026-07-28` on a client that never advertised it emulates a + * Offering `2026-07-28` on a client that never advertised it would emulate a * product capability that does not exist. A client supports a revision when it * lists that revision — there is no separate stateless-support flag. * * Exempt from the filter: - * - `"auto"`, which stores no pin at all and so claims nothing. + * - `"auto"`, which is a negotiation policy rather than a concrete pin. * - The stored value, so a row already pinned outside its own advertised list * keeps rendering its selection instead of silently reading as "Automatic". * Same don't-strand-the-user rule as the policy controls further down. @@ -226,7 +222,7 @@ type ProtocolDoc = { * persistence; per-server pins live on the server card's Connection * overrides section. */ - mcpProtocolVersion?: McpProtocolVersion; + mcpProtocolVersion?: HostProtocolDropdownValue; /** * Whether the simulated client mirrors `x-mcp-header` tool arguments into * `Mcp-Param-*` request headers (SEP-2243, 2026-07-28). Absent → `"mirror"`, @@ -529,14 +525,13 @@ export function applyJsonToDraft( if (cleaned.length > 0) supportedProtocolVersions = cleaned; } - // mcpProtocolVersion — membership-gate via `isKnownProtocolVersion` - // so typo strings fall back to `undefined` (= "SDK default") rather - // than slipping through to the SDK's open-routing predicate. Absent - // / wrong type also collapses to undefined for the same canonical- - // hash-stability reason documented in the type. - let mcpProtocolVersion: McpProtocolVersion | undefined; + // `auto` is a stored selection policy; every other accepted value is a + // concrete wire revision. Typo strings still collapse to undefined. + let mcpProtocolVersion: HostProtocolDropdownValue | undefined; const rawProtocolVersion = parsed.mcpProtocolVersion; - if ( + if (rawProtocolVersion === "auto") { + mcpProtocolVersion = "auto"; + } else if ( typeof rawProtocolVersion === "string" && isKnownProtocolVersion(rawProtocolVersion) ) { @@ -670,8 +665,10 @@ export function ProtocolTab({ // "Automatic" — matching what the connect path does with them anyway. const storedProtocolVersion = draft.mcpProfile?.mcpProtocolVersion; const selectedDropdownValue: HostProtocolDropdownValue = - storedProtocolVersion !== undefined && - isKnownProtocolVersion(storedProtocolVersion) + storedProtocolVersion === "auto" + ? "auto" + : storedProtocolVersion !== undefined && + isKnownProtocolVersion(storedProtocolVersion) ? storedProtocolVersion : "auto"; @@ -689,23 +686,20 @@ export function ProtocolTab({ ); const protocolOptionsRestricted = protocolOptions.length < HOST_PROTOCOL_OPTIONS.length; - // A stored stateful pin outside the advertised list — a legacy row, or one + // A stored concrete pin outside the advertised list — a legacy row, or one // hand-edited in the JSON. Its option is force-kept (see the helper), which // can pad the list back to full length, so this must be detected directly // rather than inferred from the option count. Saving such a draft throws // `ConflictingProtocolVersionPin`; warn before Save does. const selectedPinUnadvertised = selectedDropdownValue !== "auto" && - !isStatelessProtocolVersion(selectedDropdownValue) && advertisedProtocolVersions !== undefined && advertisedProtocolVersions.length > 0 && !advertisedProtocolVersions.includes(selectedDropdownValue); - // Dropdown handler. Writes through to `draft.mcpProfile.mcpProtocolVersion` - // directly (parallel to the JSON editor's applyJsonToDraft path) so the - // JSON view round-trips immediately. Maps the UI-only "default" sentinel - // to `undefined` — preserves canonical-hash stability so the SDK can - // upgrade its default version without churning every stored host config. + // Dropdown handler. `undefined` here means the user selected Automatic; + // persist the explicit policy so ChatGPT's default can differ from a legacy + // row whose field is genuinely absent. const setProtocolVersion = (next: McpProtocolVersion | undefined) => { const warning = legacyProtocolSupportWarning( draft.hostStyle, @@ -736,7 +730,7 @@ export function ProtocolTab({ const updated: HostConfigMcpProfileV1 = { ...base, initialize, - mcpProtocolVersion: next, + mcpProtocolVersion: next ?? "auto", }; return { ...prev, @@ -870,7 +864,7 @@ export function ProtocolTab({
{fProtocolVersion.label} @@ -905,7 +899,7 @@ export function ProtocolTab({ materially, so they get their own copy: a stateless pin has no legacy fallback, while a stateful pin narrows the initialize handshake to that one version. "Automatic" gets no line — the - absence of a pin needs no explanation and keeps the panel quiet in + selection policy needs no extra explanation and keeps the panel quiet in the default state. */} {selectedDropdownValue !== "auto" && (

@@ -917,8 +911,7 @@ export function ProtocolTab({ {/* Without this line a preset-backed client reads as a broken control: the missing revisions look arbitrary, and the list that removed them is invisible unless the JSON editor below is open. Name both. The - claim is scoped to pre-2026 revisions — stateless versions skip the - initialize handshake and stay pinnable regardless of the list. */} + list constrains every concrete pin, including 2026. */} {protocolOptionsRestricted && (

This client advertises{" "} diff --git a/mcpjam-inspector/client/src/components/hosts/redesigned/focus/__tests__/ProtocolTab.versionDropdown.test.tsx b/mcpjam-inspector/client/src/components/hosts/redesigned/focus/__tests__/ProtocolTab.versionDropdown.test.tsx index 286353cfe2..17f3bcb2d5 100644 --- a/mcpjam-inspector/client/src/components/hosts/redesigned/focus/__tests__/ProtocolTab.versionDropdown.test.tsx +++ b/mcpjam-inspector/client/src/components/hosts/redesigned/focus/__tests__/ProtocolTab.versionDropdown.test.tsx @@ -118,7 +118,7 @@ describe("ProtocolTab protocol-version dropdown", () => { expect(screen.getByTestId("pin").textContent).toBe("2025-06-18"); }); - it("defaults an unpinned host to Automatic and stores no pin", () => { + it("renders a legacy unpinned host as Automatic without rewriting it", () => { render(); expect( @@ -127,7 +127,7 @@ describe("ProtocolTab protocol-version dropdown", () => { expect(screen.getByTestId("pin").textContent).toBe(""); }); - it("selecting Latest pins 2026-07-28; returning to Automatic clears it", async () => { + it("selecting Latest pins 2026-07-28; returning to Automatic stores auto", async () => { const user = userEvent.setup(); render(); @@ -137,14 +137,12 @@ describe("ProtocolTab protocol-version dropdown", () => { await user.click(screen.getByRole("option", { name: /Latest/i })); expect(screen.getByTestId("pin").textContent).toBe("2026-07-28"); - // Back to Automatic must restore ABSENCE, not a 2025 literal — a stored - // literal would churn the canonical config hash against every - // pre-feature row. + // Automatic is an explicit selection policy, not a wire protocol literal. await user.click( screen.getByRole("combobox", { name: "MCP protocol version" }) ); await user.click(screen.getByRole("option", { name: "Automatic" })); - expect(screen.getByTestId("pin").textContent).toBe(""); + expect(screen.getByTestId("pin").textContent).toBe("auto"); }); it("shows a stored legacy pin as itself instead of collapsing it", () => { @@ -178,7 +176,7 @@ describe("ProtocolTab protocol-version dropdown", () => { }); /** - * The backend (`canonicalizeMcpProfile`) refuses to store a STATEFUL pin that + * The backend (`canonicalizeMcpProfile`) refuses to store a concrete pin that * is absent from `initialize.supportedProtocolVersions` — * `ConflictingProtocolVersionPin`. Preset-backed clients carry that list, so * offering the full set on them produced choices that failed at Save with an @@ -261,11 +259,9 @@ describe("ProtocolTab dropdown vs. the client's advertised versions", () => { screen.getByRole("combobox", { name: "MCP protocol version" }) ); - // The backend WOULD accept a stateless pin here (it only validates - // stateful ones), but accepting it is not the same as the client speaking - // it: offering 2026-07-28 to a client that never advertised it emulates a - // capability the real product does not have. Only Automatic — which claims - // nothing — survives alongside the advertised revision. + // Every concrete pin is constrained by the advertised support list, + // including the stateless 2026 revision. Automatic remains available as + // the negotiation policy. expect( (await screen.findAllByRole("option")).map((o) => o.textContent) ).toEqual(["Automatic", "November (2025-11-25)"]); @@ -390,7 +386,7 @@ describe("ProtocolTab dropdown vs. the client's advertised versions", () => { ).toBeInTheDocument(); }); - it("does not warn on an advertised or stateless pin", () => { + it("does not warn on an advertised pin and warns on any unadvertised pin", () => { // Advertised pin: fine. const { unmount } = render( @@ -398,11 +394,8 @@ describe("ProtocolTab dropdown vs. the client's advertised versions", () => { expect(screen.queryByText(/does not advertise/)).toBeNull(); unmount(); - // Stateless pin: the dropdown no longer OFFERS an unadvertised stateless - // version, but a config that already carries one still saves — the backend - // rule never reaches it. The warning speaks only to save failure, so it - // must stay silent here rather than crying wolf. + // The stateless wire path also has to match the client's advertised list. render(); - expect(screen.queryByText(/does not advertise/)).toBeNull(); + expect(screen.getByText(/does not advertise/)).toBeInTheDocument(); }); }); diff --git a/mcpjam-inspector/client/src/lib/__tests__/client-config-v2-mcp-profile.test.ts b/mcpjam-inspector/client/src/lib/__tests__/client-config-v2-mcp-profile.test.ts index 5ec6fe4f41..eecd131f01 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/client-config-v2-mcp-profile.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/client-config-v2-mcp-profile.test.ts @@ -73,14 +73,12 @@ describe("resolveClientInfo", () => { }); test("returns undefined when initialize.clientInfo is unset (even with profile present)", () => { - expect( - resolveClientInfo({ profileVersion: 1 }), - ).toBeUndefined(); + expect(resolveClientInfo({ profileVersion: 1 })).toBeUndefined(); expect( resolveClientInfo({ profileVersion: 1, initialize: { supportedProtocolVersions: ["2025-11-25"] }, - }), + }) ).toBeUndefined(); }); @@ -152,14 +150,13 @@ describe("emptyHostConfigInputV2 mcpProfile handling", () => { // (Hit this exact bug during initial test writing — shared mutable // fixtures + an aliasing assertion is a foot-gun.) const source = JSON.parse( - JSON.stringify(SAMPLE_PROFILE), + JSON.stringify(SAMPLE_PROFILE) ) as HostConfigMcpProfileV1; const partial: Partial = { mcpProfile: source }; const input = emptyHostConfigInputV2(partial); expect(input.mcpProfile).toEqual(SAMPLE_PROFILE); // Mutate the source — input must be unaffected. - (source.initialize!.clientInfo as Record).name = - "mutated"; + (source.initialize!.clientInfo as Record).name = "mutated"; expect(input.mcpProfile?.initialize?.clientInfo?.name).toBe("chatgpt"); }); }); @@ -173,7 +170,7 @@ describe("hostConfigDtoToInput mcpProfile round-trip", () => { test("DTO with mcpProfile → input with cloned mcpProfile", () => { // Same aliasing-test trap — deep-clone the fixture before mutation. const sourceProfile = JSON.parse( - JSON.stringify(SAMPLE_PROFILE), + JSON.stringify(SAMPLE_PROFILE) ) as HostConfigMcpProfileV1; const dto = { ...BASE_DTO, mcpProfile: sourceProfile }; const input = hostConfigDtoToInput(dto); @@ -282,7 +279,7 @@ describe("hostConfigInputsEqual mcpProfile semantics", () => { }); describe("resolveEffectiveCompatRuntime — per-method capability matrix", () => { - test('host style with `compatRuntime.openaiApps: false` resolves to `{ injected: false }` regardless of overrides', () => { + test("host style with `compatRuntime.openaiApps: false` resolves to `{ injected: false }` regardless of overrides", () => { // Claude doesn't inject the shim. Per-method overrides without // injection are meaningless — the resolver must short-circuit. const result = resolveEffectiveCompatRuntime({ @@ -391,20 +388,20 @@ describe("resolveEffectiveCompatRuntime — per-method capability matrix", () => // but the runtime always uses the host default). describe("resolveEffectiveMcpProtocolVersion — per-server override precedence", () => { test("server override wins over host default", () => { - expect( - resolveEffectiveMcpProtocolVersion("2026-07-28", "2025-11-25"), - ).toBe("2026-07-28"); + expect(resolveEffectiveMcpProtocolVersion("2026-07-28", "2025-11-25")).toBe( + "2026-07-28" + ); }); test("host default applies when no server override", () => { expect(resolveEffectiveMcpProtocolVersion(undefined, "2025-11-25")).toBe( - "2025-11-25", + "2025-11-25" ); }); test("returns undefined when neither layer has an opinion (SDK default semantics)", () => { expect(resolveEffectiveMcpProtocolVersion(undefined, undefined)).toBe( - undefined, + undefined ); }); @@ -413,9 +410,9 @@ describe("resolveEffectiveMcpProtocolVersion — per-server override precedence" // migration test, one legacy server overridden back to 2025-11-25. // The override must reach the connect path or the legacy server // will fail with -32004. - expect( - resolveEffectiveMcpProtocolVersion("2025-11-25", "2026-07-28"), - ).toBe("2025-11-25"); + expect(resolveEffectiveMcpProtocolVersion("2025-11-25", "2026-07-28")).toBe( + "2025-11-25" + ); }); }); @@ -425,7 +422,10 @@ describe("isMcpProfileEmpty (shared by every profile write path)", () => { // inlined at four write sites, so a field one of them didn't know about // silently collapsed the profile — losing the user's setting on save. for (const profile of [ - { profileVersion: 1 as const, paginationTraversal: "firstPageOnly" as const }, + { + profileVersion: 1 as const, + paginationTraversal: "firstPageOnly" as const, + }, { profileVersion: 1 as const, mrtrSupport: "none" as const }, { profileVersion: 1 as const, toolParamHeaderMirroring: "omit" as const }, ]) { @@ -436,20 +436,18 @@ describe("isMcpProfileEmpty (shared by every profile write path)", () => { it("treats an EMPTY initialize envelope as empty", () => { // The canonicalizer drops an empty `initialize`, so persisting a profile // that holds only one would mint a row hashing identically to no profile. - expect( - isMcpProfileEmpty({ profileVersion: 1, initialize: {} }), - ).toBe(true); + expect(isMcpProfileEmpty({ profileVersion: 1, initialize: {} })).toBe(true); expect( isMcpProfileEmpty({ profileVersion: 1, initialize: { supportedProtocolVersions: [] }, - }), + }) ).toBe(true); expect( isMcpProfileEmpty({ profileVersion: 1, initialize: { supportedProtocolVersions: ["2026-07-28"] }, - }), + }) ).toBe(false); }); diff --git a/mcpjam-inspector/client/src/lib/host-config-field-schema.ts b/mcpjam-inspector/client/src/lib/host-config-field-schema.ts index cb2efbe7ac..7838352713 100644 --- a/mcpjam-inspector/client/src/lib/host-config-field-schema.ts +++ b/mcpjam-inspector/client/src/lib/host-config-field-schema.ts @@ -471,15 +471,16 @@ export const HOST_CONFIG_FIELDS: ReadonlyArray = [ label: "Protocol version", path: "mcpProfile.mcpProtocolVersion", description: - "Host default pin. Per-server overrides win. Undefined = SDK chooses at request time.", + 'Host default selection. "auto" negotiates at connect time; concrete versions pin that exact era. Per-server overrides win.', kind: { kind: "enum", options: [ + "auto", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28", - ] as ReadonlyArray, + ] as ReadonlyArray, }, read: (cfg) => mcpProfile(cfg)?.mcpProtocolVersion, }, diff --git a/sdk/src/host-compat/catalog.generated.ts b/sdk/src/host-compat/catalog.generated.ts index f110971141..01fe802e7a 100644 --- a/sdk/src/host-compat/catalog.generated.ts +++ b/sdk/src/host-compat/catalog.generated.ts @@ -638,10 +638,10 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { chatgpt: { id: "chatgpt", label: "ChatGPT", - provenance: "vendor-doc", + provenance: "probe", rendersMcpApps: true, - supportedProtocolVersions: ["2025-11-25"], - verifiedAt: 1784764800000, + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + verifiedAt: 1785974400000, modelVisibleMcpToolResults: { directContent: { image: true, @@ -705,7 +705,7 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { availableDisplayModes: ["inline", "fullscreen", "pip"], containerDimensions: { height: 400, - maxWidth: 768, + maxWidth: 672, }, locale: "en-US", timeZone: "America/Los_Angeles", @@ -724,8 +724,14 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { }, mcpProfile: { profileVersion: 1, + mcpProtocolVersion: "auto", + toolCallCancellation: false, + mrtrModes: { + requestState: true, + elicitation: false, + }, initialize: { - supportedProtocolVersions: ["2025-11-25"], + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], clientInfo: { name: "openai-mcp", version: "1.0.0", @@ -740,6 +746,21 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { }, compatRuntime: { openaiApps: true, + openaiAppsOverrides: { + callTool: true, + sendFollowUpMessage: true, + setWidgetState: true, + requestDisplayMode: "all", + notifyIntrinsicHeight: false, + openExternal: true, + setOpenInAppUrl: true, + requestModal: true, + uploadFile: true, + selectFiles: true, + getFileDownloadUrl: true, + requestCheckout: true, + requestClose: true, + }, }, sandbox: { csp: { @@ -763,10 +784,7 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { }, mcpAppsOverrides: { availableDisplayModes: ["inline", "fullscreen", "pip"], - toolInputPartial: true, - toolCancelled: true, hostContextChanged: true, - resourceTeardown: true, toolInfo: true, openLinks: true, serverTools: true, @@ -777,9 +795,30 @@ export const BUNDLED_HOST_COMPAT_CATALOG = { sandboxPermissions: true, cspFrameDomains: true, cspBaseUriDomains: true, + cspConnectDomains: { + fetch: false, + xhr: false, + websocket: true, + }, + cspResourceDomains: { + script: false, + stylesheet: false, + image: false, + font: false, + media: false, + }, + resourceCacheTtl: true, resourcePrefersBorder: true, - downloadFile: false, - requestTeardown: true, + containerSizing: { + defaultWidth: 670, + defaultHeight: 398, + width: "grows", + height: "grows", + testedUpToWidth: 10000, + testedUpToHeight: 10000, + limitObserved: false, + }, + requestTeardown: false, widgetDisplayModeRequests: "accept", }, }, diff --git a/sdk/src/host-config/canonicalize.ts b/sdk/src/host-config/canonicalize.ts index 594fcd83bf..ce3831b0e3 100644 --- a/sdk/src/host-config/canonicalize.ts +++ b/sdk/src/host-config/canonicalize.ts @@ -694,12 +694,15 @@ function canonicalizeMcpProfile( const out: HostConfigMcpProfileV1 = { profileVersion: 1 }; - // Host-default pinned MCP protocol version. Absent → SDK chooses at resolve - // time; we drop the field when absent so pre-feature rows hash identically. + // Host-default protocol selection. `auto` is a storage-only policy; concrete + // values are wire pins. Absent stays accepted for legacy rows. if (input.mcpProtocolVersion !== undefined) { - if (!isKnownProtocolVersion(input.mcpProtocolVersion)) { + if ( + input.mcpProtocolVersion !== "auto" && + !isKnownProtocolVersion(input.mcpProtocolVersion) + ) { throw new Error( - `hostConfigV2: mcpProfile.mcpProtocolVersion must be one of ${MCP_PROTOCOL_VERSIONS.join( + `hostConfigV2: mcpProfile.mcpProtocolVersion must be "auto" or one of ${MCP_PROTOCOL_VERSIONS.join( ", " )} (got "${String(input.mcpProtocolVersion)}")` ); @@ -817,17 +820,19 @@ function canonicalizeMcpProfile( } } - // Cross-field rule (Option A): when `mcpProtocolVersion` pins a stateful - // (pre-2026) version, the legacy `initialize` handshake runs and must - // advertise that exact version. Derive when caller didn't set one; throw if - // they set both AND the pin isn't in the list. Stateless versions skip - // initialize entirely, so leave `supportedProtocolVersions` alone there. + // Any concrete pin must be one of the client's declared supported versions. + // For legacy pins, derive a missing list because initialize needs one. A + // modern pin does not run initialize, but the stored list still describes + // what this client can speak and therefore cannot contradict the pin. if ( out.mcpProtocolVersion !== undefined && - !isStatelessProtocolVersion(out.mcpProtocolVersion) + out.mcpProtocolVersion !== "auto" ) { const advertised = out.initialize?.supportedProtocolVersions; - if (advertised === undefined) { + if ( + advertised === undefined && + !isStatelessProtocolVersion(out.mcpProtocolVersion) + ) { const initBase = out.initialize ?? {}; const initWithDerived: NonNullable = { @@ -841,7 +846,10 @@ function canonicalizeMcpProfile( )[k]; } out.initialize = sortedInit; - } else if (!advertised.includes(out.mcpProtocolVersion)) { + } else if ( + advertised !== undefined && + !advertised.includes(out.mcpProtocolVersion) + ) { throw new Error( `hostConfigV2: ConflictingProtocolVersionPin — mcpProtocolVersion "${ out.mcpProtocolVersion diff --git a/sdk/src/host-config/defaults.ts b/sdk/src/host-config/defaults.ts index 684a0bf5e0..f7eb68459c 100644 --- a/sdk/src/host-config/defaults.ts +++ b/sdk/src/host-config/defaults.ts @@ -41,7 +41,8 @@ export const DEFAULT_TEMPERATURE_V2 = 0.7; */ export function resolveEffectiveMcpProtocolVersion( serverOverride: McpProtocolVersion | undefined, - hostDefault: McpProtocolVersion | undefined, + hostDefault: McpProtocolVersion | "auto" | undefined ): McpProtocolVersion | undefined { - return serverOverride ?? hostDefault; + if (serverOverride !== undefined) return serverOverride; + return hostDefault === "auto" ? undefined : hostDefault; } diff --git a/sdk/src/host-config/host-connection.ts b/sdk/src/host-config/host-connection.ts index c9e604ed1a..a819e4130d 100644 --- a/sdk/src/host-config/host-connection.ts +++ b/sdk/src/host-config/host-connection.ts @@ -61,7 +61,7 @@ function isRecord(value: unknown): value is Record { * (Not the public `Host.toJSON()` shape, which uses `mcp.protocolVersion` etc.) */ export function hostConnectionProfile( - hostConfig: Record, + hostConfig: Record ): HostConnectionProfile { const mcpProfile = isRecord(hostConfig.mcpProfile) ? hostConfig.mcpProfile @@ -79,15 +79,16 @@ export function hostConnectionProfile( : undefined; const supportedProtocolVersions = Array.isArray( - initialize?.supportedProtocolVersions, + initialize?.supportedProtocolVersions ) ? (initialize.supportedProtocolVersions as unknown[]).filter( - (v): v is string => typeof v === "string", + (v): v is string => typeof v === "string" ) : undefined; const mcpProtocolVersion = - typeof mcpProfile?.mcpProtocolVersion === "string" + typeof mcpProfile?.mcpProtocolVersion === "string" && + mcpProfile.mcpProtocolVersion !== "auto" ? mcpProfile.mcpProtocolVersion : undefined; diff --git a/sdk/src/host-config/public-types.ts b/sdk/src/host-config/public-types.ts index c2a134dd66..eac8dc5388 100644 --- a/sdk/src/host-config/public-types.ts +++ b/sdk/src/host-config/public-types.ts @@ -92,8 +92,8 @@ export type HostMcp = Omit< HostConfigMcpProfileV1, "profileVersion" | "mcpProtocolVersion" > & { - /** Host-default pinned MCP protocol version (e.g. "2025-11-25"). */ - protocolVersion?: McpProtocolVersion; + /** Automatic negotiation or one concrete host-default wire pin. */ + protocolVersion?: McpProtocolVersion | "auto"; }; /** diff --git a/sdk/src/host-config/templates/seed-host-template.ts b/sdk/src/host-config/templates/seed-host-template.ts index bbab9ce5b0..217ac19f4f 100644 --- a/sdk/src/host-config/templates/seed-host-template.ts +++ b/sdk/src/host-config/templates/seed-host-template.ts @@ -842,10 +842,11 @@ export const HOST_TEMPLATES: readonly HostTemplate[] = [ // an MCP app can never reach a domain ChatGPT itself wouldn't allow. base.mcpProfile = { profileVersion: 1, + mcpProtocolVersion: "auto", initialize: { - supportedProtocolVersions: ["2025-11-25"], - // Base MCP protocol: clientInfo sent to MCP servers during - // `initialize`. Matches what real ChatGPT publishes. + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + // Stored in the established connection-profile envelope. The + // runtime sends initialize only when a 2025 protocol is selected. clientInfo: { name: "openai-mcp", version: "1.0.0" }, }, apps: { diff --git a/sdk/src/host-config/types.ts b/sdk/src/host-config/types.ts index 88dd0f28f1..33d7164c15 100644 --- a/sdk/src/host-config/types.ts +++ b/sdk/src/host-config/types.ts @@ -267,9 +267,10 @@ export type CspDomainSet = { // NOT validated here — that's a UI/SDK concern. export type HostConfigMcpProfileV1 = { profileVersion: 1; - // Host-default pinned MCP protocol version. Absent → SDK chooses at - // request time. Per-server pins live on serverConnectionOverrides. - mcpProtocolVersion?: McpProtocolVersion; + // Host-default protocol selection. `"auto"` negotiates at connect time; + // concrete revisions pin that exact wire era. Per-server pins live on + // serverConnectionOverrides and win over this default. + mcpProtocolVersion?: McpProtocolVersion | "auto"; // Whether the simulated client mirrors `x-mcp-header` tool arguments into // `Mcp-Param-*` request headers (SEP-2243). Absent → `"mirror"`, the // spec-conforming default; `"omit"` simulates a non-conforming client so a diff --git a/sdk/src/mcp-client-manager/MCPClientManager.ts b/sdk/src/mcp-client-manager/MCPClientManager.ts index ede5d025e2..5fbc247ade 100644 --- a/sdk/src/mcp-client-manager/MCPClientManager.ts +++ b/sdk/src/mcp-client-manager/MCPClientManager.ts @@ -2296,16 +2296,13 @@ export class MCPClientManager { const wantsStateless = resolvedProtocolVersion !== undefined && isStatelessProtocolVersion(resolvedProtocolVersion); - // Resolve negotiation before the legacy initialize accept-list so Auto - // has one source of truth. An unpinned connection probes the modern era - // first and, on a legacy signal, must fall back with the upstream SDK's - // complete built-in supported-version list. Forwarding a persisted - // per-server or manager-default list here can make the first connection - // succeed but a later reconnect reject the server's valid counter-offer. + // Resolve negotiation independently from the client's support list. The + // list says which revisions the client can speak; the selection says + // whether to auto-negotiate or pin one era. Upstream uses the same list + // to constrain modern discovery candidates and legacy fallback. const versionNegotiation = resolveVersionNegotiation( resolvedProtocolVersion ); - const wantsAutoNegotiation = versionNegotiation?.mode === "auto"; // Stateful `mcpProtocolVersion` pin (e.g. `"2025-11-25"`) propagates // into the legacy `Client`'s `supportedProtocolVersions` accept-list // so `initialize.params.protocolVersion` actually goes out as the @@ -2313,15 +2310,16 @@ export class MCPClientManager { // explicit `supportedProtocolVersions` (per-server or default) still // wins for an explicit pin — pinning at one layer while overriding the // other would be ambiguous and the override is the more specific signal. - // Auto deliberately ignores both lists so its legacy fallback negotiates - // against every version supported by the upstream SDK. - const resolvedSupportedProtocolVersions = wantsAutoNegotiation - ? undefined - : config.supportedProtocolVersions ?? - this.defaultSupportedProtocolVersions ?? - (!wantsStateless && resolvedProtocolVersion !== undefined - ? [resolvedProtocolVersion] - : undefined); + // Automatic mode honors the same declared support list. This is what + // lets a host truthfully say "2025-11-25 + 2026-07-28" once, then let + // the SDK select between those eras without accidentally falling back + // to some other legacy revision. + const resolvedSupportedProtocolVersions = + config.supportedProtocolVersions ?? + this.defaultSupportedProtocolVersions ?? + (!wantsStateless && resolvedProtocolVersion !== undefined + ? [resolvedProtocolVersion] + : undefined); // Send the version that was actually PINNED, not whatever happens to // sit at index 0 of the accept-list. // @@ -2340,7 +2338,7 @@ export class MCPClientManager { // // A pin absent from the list is left alone: it would put a version on // the wire the client never claimed to speak. `canonicalizeMcpProfile` - // rejects that combination for stateful pins anyway, so this only + // rejects that combination for concrete host pins anyway, so this only // guards hand-built configs. const supportedProtocolVersions = !wantsStateless && diff --git a/sdk/tests/MCPClientManager.auto-protocol-reconnect.test.ts b/sdk/tests/MCPClientManager.auto-protocol-reconnect.test.ts index 569ec7d5ba..52ebd35d19 100644 --- a/sdk/tests/MCPClientManager.auto-protocol-reconnect.test.ts +++ b/sdk/tests/MCPClientManager.auto-protocol-reconnect.test.ts @@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let sseConstructedCount = 0; vi.mock("@modelcontextprotocol/client", async (importOriginal) => { - const actual = - await importOriginal(); + const actual = await importOriginal< + typeof import("@modelcontextprotocol/client") + >(); class SpySSEClientTransport extends actual.SSEClientTransport { constructor(url: URL, opts?: Record) { super(url, opts as never); @@ -19,9 +20,7 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => { }; }); -const { MCPClientManager } = await import( - "../src/mcp-client-manager/index.js" -); +const { MCPClientManager } = await import("../src/mcp-client-manager/index.js"); const NEGOTIATED_VERSION = "2025-06-18"; const STALE_ACCEPT_LIST = ["2025-11-25"]; @@ -130,7 +129,7 @@ describe("MCPClientManager Automatic legacy fallback", () => { await fixture.close(); }); - it("accepts 2025-06-18 after disconnect and reconnect despite a stale list", async () => { + it("honors a per-server support list after disconnect and reconnect", async () => { await manager.connectToServer("bart", { url: fixture.url, timeout: 5_000, @@ -140,15 +139,13 @@ describe("MCPClientManager Automatic legacy fallback", () => { ); await manager.removeServer("bart"); - await manager.connectToServer("bart", { - url: fixture.url, - timeout: 5_000, - supportedProtocolVersions: STALE_ACCEPT_LIST, - }); - - expect(manager.getInitializationInfo("bart")?.protocolVersion).toBe( - NEGOTIATED_VERSION - ); + await expect( + manager.connectToServer("bart", { + url: fixture.url, + timeout: 5_000, + supportedProtocolVersions: STALE_ACCEPT_LIST, + }) + ).rejects.toThrow(/Server's protocol version is not supported: 2025-06-18/); const initializeRequests = fixture.requests.filter( (request) => request.rpcMethod === "initialize" ); @@ -156,24 +153,22 @@ describe("MCPClientManager Automatic legacy fallback", () => { expect( initializeRequests.map((request) => request.proposedProtocolVersion) ).toEqual(["2025-11-25", "2025-11-25"]); - expect(sseConstructedCount).toBe(0); + expect(sseConstructedCount).toBe(1); }); - it("ignores a stale manager-default accept-list in Automatic mode", async () => { + it("honors the manager-default support list in Automatic mode", async () => { await manager.disconnectAllServers(); manager = new MCPClientManager( {}, { defaultSupportedProtocolVersions: STALE_ACCEPT_LIST } ); - await manager.connectToServer("bart", { - url: fixture.url, - timeout: 5_000, - }); - - expect(manager.getInitializationInfo("bart")?.protocolVersion).toBe( - NEGOTIATED_VERSION - ); + await expect( + manager.connectToServer("bart", { + url: fixture.url, + timeout: 5_000, + }) + ).rejects.toThrow(/Server's protocol version is not supported: 2025-06-18/); }); it("keeps an explicit legacy protocol pin strict", async () => { @@ -244,7 +239,7 @@ describe("MCPClientManager Automatic legacy fallback", () => { it("leaves a pin that the accept-list does not contain alone", async () => { // Hoisting an unlisted pin would put a version on the wire this client // never claimed to speak. `canonicalizeMcpProfile` rejects that pairing - // for stateful pins, so this only guards hand-built configs. + // for concrete host pins, so this only guards hand-built configs. await manager.connectToServer("bart", { url: fixture.url, timeout: 5_000, diff --git a/sdk/tests/__snapshots__/host-config-seed-host-template.test.ts.snap b/sdk/tests/__snapshots__/host-config-seed-host-template.test.ts.snap index c778062268..320377d234 100644 --- a/sdk/tests/__snapshots__/host-config-seed-host-template.test.ts.snap +++ b/sdk/tests/__snapshots__/host-config-seed-host-template.test.ts.snap @@ -185,8 +185,10 @@ exports[`seedHostTemplate > seed output matches the committed golden snapshot 1` }, "supportedProtocolVersions": [ "2025-11-25", + "2026-07-28", ], }, + "mcpProtocolVersion": "auto", "profileVersion": 1, }, "modelId": "openai/gpt-5-nano", @@ -302,8 +304,10 @@ exports[`seedHostTemplate > seed output matches the committed golden snapshot 1` }, "supportedProtocolVersions": [ "2025-11-25", + "2026-07-28", ], }, + "mcpProtocolVersion": "auto", "profileVersion": 1, }, "modelId": "openai/gpt-5-nano", diff --git a/sdk/tests/host-compat-market-hosts.test.ts b/sdk/tests/host-compat-market-hosts.test.ts index c40735d0a1..2dc86a3cc3 100644 --- a/sdk/tests/host-compat-market-hosts.test.ts +++ b/sdk/tests/host-compat-market-hosts.test.ts @@ -60,8 +60,8 @@ describe("buildMarketHostProfiles", () => { expect(profileFor("chatgpt")?.capabilities).toMatchObject({ serverResources: true, logging: true, - downloadFile: false, }); + expect(profileFor("chatgpt")?.capabilities?.downloadFile).toBeUndefined(); }); it("carries each host's advertised protocol versions (or none)", () => { diff --git a/sdk/tests/host-config-canonicalize.test.ts b/sdk/tests/host-config-canonicalize.test.ts index cae4204868..14abfe5eac 100644 --- a/sdk/tests/host-config-canonicalize.test.ts +++ b/sdk/tests/host-config-canonicalize.test.ts @@ -437,6 +437,28 @@ describe("canonicalizeHostConfigV2 — mcpProfile derivation", () => { expect(c.mcpProfile?.initialize).toBeUndefined(); }); + it("preserves automatic dual-era selection in the existing initialize envelope", () => { + const c = canonicalizeHostConfigV2( + base({ + mcpProfile: { + profileVersion: 1, + mcpProtocolVersion: "auto", + initialize: { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + clientInfo: { name: "openai-mcp", version: "1.0.0" }, + }, + }, + }) + ); + expect(c.mcpProfile).toMatchObject({ + mcpProtocolVersion: "auto", + initialize: { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + clientInfo: { name: "openai-mcp", version: "1.0.0" }, + }, + }); + }); + it("throws ConflictingProtocolVersionPin when pin not advertised", () => { expect(() => canonicalizeHostConfigV2( @@ -450,13 +472,29 @@ describe("canonicalizeHostConfigV2 — mcpProfile derivation", () => { ) ).toThrow(/ConflictingProtocolVersionPin/); }); + + it("also rejects an unadvertised concrete 2026 pin", () => { + expect(() => + canonicalizeHostConfigV2( + base({ + mcpProfile: { + profileVersion: 1, + mcpProtocolVersion: "2026-07-28", + initialize: { supportedProtocolVersions: ["2025-11-25"] }, + }, + }) + ) + ).toThrow(/ConflictingProtocolVersionPin/); + }); }); describe("canonicalizeHostConfigV2 — toolParamHeaderMirroring", () => { it("round-trips both literals", () => { for (const mode of ["mirror", "omit"] as const) { const c = canonicalizeHostConfigV2( - base({ mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: mode } }) + base({ + mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: mode }, + }) ); expect(c.mcpProfile?.toolParamHeaderMirroring).toBe(mode); } @@ -474,9 +512,17 @@ describe("canonicalizeHostConfigV2 — toolParamHeaderMirroring", () => { it("does not collide with an untouched profile's hash", async () => { expect( - await hash(base({ mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: "omit" } })) + await hash( + base({ + mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: "omit" }, + }) + ) ).not.toBe( - await hash(base({ mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: "mirror" } })) + await hash( + base({ + mcpProfile: { profileVersion: 1, toolParamHeaderMirroring: "mirror" }, + }) + ) ); }); @@ -486,8 +532,7 @@ describe("canonicalizeHostConfigV2 — toolParamHeaderMirroring", () => { base({ mcpProfile: { profileVersion: 1, - toolParamHeaderMirroring: - "corrupt" as unknown as "mirror", + toolParamHeaderMirroring: "corrupt" as unknown as "mirror", }, }) ) @@ -789,7 +834,10 @@ describe("canonicalizeHostConfigV2 — skillSelection", () => { it("dedupes and sorts explicit skillIds deterministically (order-insensitive)", async () => { const c = canonicalizeHostConfigV2( base({ - skillSelection: { mode: "explicit", skillIds: ["sk-b", "sk-a", "sk-b"] }, + skillSelection: { + mode: "explicit", + skillIds: ["sk-b", "sk-a", "sk-b"], + }, }) ); expect(c.skillSelection).toEqual({ @@ -798,7 +846,9 @@ describe("canonicalizeHostConfigV2 — skillSelection", () => { }); expect( await hash( - base({ skillSelection: { mode: "explicit", skillIds: ["sk-a", "sk-b"] } }) + base({ + skillSelection: { mode: "explicit", skillIds: ["sk-a", "sk-b"] }, + }) ) ).toBe( await hash( diff --git a/sdk/tests/host-config-defaults.test.ts b/sdk/tests/host-config-defaults.test.ts index ada26039eb..19f46fc44a 100644 --- a/sdk/tests/host-config-defaults.test.ts +++ b/sdk/tests/host-config-defaults.test.ts @@ -12,32 +12,44 @@ describe("DEFAULT_TEMPERATURE_V2", () => { describe("resolveEffectiveMcpProtocolVersion", () => { it("returns the per-server override when present (server wins over host default)", () => { - expect( - resolveEffectiveMcpProtocolVersion("2025-06-18", "2025-11-25"), - ).toBe("2025-06-18"); + expect(resolveEffectiveMcpProtocolVersion("2025-06-18", "2025-11-25")).toBe( + "2025-06-18" + ); }); it("returns the host default when the server has no override", () => { + expect(resolveEffectiveMcpProtocolVersion(undefined, "2025-11-25")).toBe( + "2025-11-25" + ); + }); + + it("reduces the host Automatic policy to an unpinned connection", () => { expect( - resolveEffectiveMcpProtocolVersion(undefined, "2025-11-25"), - ).toBe("2025-11-25"); + resolveEffectiveMcpProtocolVersion(undefined, "auto") + ).toBeUndefined(); + }); + + it("lets a concrete per-server pin override the host Automatic policy", () => { + expect(resolveEffectiveMcpProtocolVersion("2026-07-28", "auto")).toBe( + "2026-07-28" + ); }); it("returns undefined when neither layer has an opinion — load-bearing sentinel: SDK chooses at request time", () => { expect( - resolveEffectiveMcpProtocolVersion(undefined, undefined), + resolveEffectiveMcpProtocolVersion(undefined, undefined) ).toBeUndefined(); }); it("returns the per-server override even when host default is also set (no merge)", () => { - expect( - resolveEffectiveMcpProtocolVersion("2026-07-28", "2025-03-26"), - ).toBe("2026-07-28"); + expect(resolveEffectiveMcpProtocolVersion("2026-07-28", "2025-03-26")).toBe( + "2026-07-28" + ); }); it("returns the per-server override when host default is undefined", () => { - expect( - resolveEffectiveMcpProtocolVersion("2025-03-26", undefined), - ).toBe("2025-03-26"); + expect(resolveEffectiveMcpProtocolVersion("2025-03-26", undefined)).toBe( + "2025-03-26" + ); }); }); diff --git a/sdk/tests/host-connection.test.ts b/sdk/tests/host-connection.test.ts index d1f5ad62d4..be31e2b349 100644 --- a/sdk/tests/host-connection.test.ts +++ b/sdk/tests/host-connection.test.ts @@ -7,7 +7,7 @@ import { const profileFor = (id: HostTemplateId) => hostConnectionProfile( - seedHostTemplate(id) as unknown as Record, + seedHostTemplate(id) as unknown as Record ); const extensions = (caps: Record | undefined) => @@ -17,14 +17,16 @@ describe("hostConnectionProfile", () => { it("derives Claude's identity + the MCP Apps UI capability", () => { const p = profileFor("claude"); expect(p.clientInfo?.name).toBe("claude-ai"); - expect(extensions(p.clientCapabilities)["io.modelcontextprotocol/ui"]).toBeDefined(); + expect( + extensions(p.clientCapabilities)["io.modelcontextprotocol/ui"] + ).toBeDefined(); // Claude's model filters app-only tools (default visibility policy). expect(p.respectToolVisibility).not.toBe(false); }); it("pins Goose's advertised protocol version", () => { expect(profileFor("goose").supportedProtocolVersions).toContain( - "2025-03-26", + "2025-03-26" ); }); @@ -54,10 +56,29 @@ describe("hostConnectionProfile", () => { expect( hostConnectionProfile({ mcpProfile: { initialize: { mcpProtocolVersion: "2026-07-28" } }, - }).mcpProtocolVersion, + }).mcpProtocolVersion ).toBeUndefined(); }); + it("reduces auto to no wire pin while preserving the nested connection profile", () => { + const p = hostConnectionProfile({ + mcpProfile: { + profileVersion: 1, + mcpProtocolVersion: "auto", + initialize: { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + clientInfo: { name: "openai-mcp", version: "1.0.0" }, + }, + }, + }); + expect(p.mcpProtocolVersion).toBeUndefined(); + expect(p.supportedProtocolVersions).toEqual(["2025-11-25", "2026-07-28"]); + expect(p.clientInfo).toMatchObject({ + name: "openai-mcp", + version: "1.0.0", + }); + }); + describe("toolParamHeaderMirroring → mirrorToolParamHeaders", () => { it('reduces "omit" to mirrorToolParamHeaders: false', () => { const p = hostConnectionProfile({ diff --git a/sdk/tests/host.test.ts b/sdk/tests/host.test.ts index 7708a38858..2cecba31fa 100644 --- a/sdk/tests/host.test.ts +++ b/sdk/tests/host.test.ts @@ -112,6 +112,25 @@ describe("Host — public surface", () => { } }); + it("round-trips automatic selection with the existing initialize profile", () => { + const host = new Host({ style: "chatgpt", model: "openai/gpt-5" }); + host.mcp.protocolVersion = "auto"; + host.mcp.initialize = { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + clientInfo: { name: "openai-mcp", version: "1.0.0" }, + }; + + const json = host.toJSON(); + expect(json.mcp).toMatchObject({ + protocolVersion: "auto", + initialize: { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + clientInfo: { name: "openai-mcp", version: "1.0.0" }, + }, + }); + expect(new Host(json).toJSON()).toEqual(json); + }); + it("serializes explicit MCP image policies", () => { const json = new Host({ style: "mcpjam", diff --git a/sdk/tests/support/dual-era-fixture.test.ts b/sdk/tests/support/dual-era-fixture.test.ts index 030e4cb380..90852cc55a 100644 --- a/sdk/tests/support/dual-era-fixture.test.ts +++ b/sdk/tests/support/dual-era-fixture.test.ts @@ -16,50 +16,45 @@ import { function byMethod( exchanges: RawExchange[], - method: string, + method: string ): RawExchange | undefined { - return exchanges.find((e) => getWireField(e.request.json, "method") === method); + return exchanges.find( + (e) => getWireField(e.request.json, "method") === method + ); } -/** - * Connect a modern-pinned client to the fixture in-process, capturing every - * frame. `handler.fetch` serves the request without a socket — the URL is - * never dialed. - */ -async function connectModern() { +async function connectFixture(mode?: "auto" | { pin: "2026-07-28" }) { const handler = createFixtureHandler(); const cap = createCapturingFetch((input, init) => - handler.fetch(new Request(input as string | URL, init)), + handler.fetch(new Request(input as string | URL, init)) ); const client = new Client( { name: "dual-era-fixture-test", version: "1.0.0" }, - { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + mode === undefined + ? undefined + : { + supportedProtocolVersions: ["2025-11-25", "2026-07-28"], + versionNegotiation: { mode }, + } ); const transport = new StreamableHTTPClientTransport( new URL("http://fixture.local/mcp"), - { fetch: cap.fetch }, + { fetch: cap.fetch } ); await client.connect(transport); return { client, cap }; } +const connectModern = () => connectFixture({ pin: "2026-07-28" }); +const connectAutomatic = () => connectFixture("auto"); + /** * Connect a default (legacy) client to the SAME handler. `createMcpHandler` * serves the 2025-era stateless idiom by default, so no pin is needed — the * client runs the ordinary `initialize` handshake. */ async function connectLegacy() { - const handler = createFixtureHandler(); - const cap = createCapturingFetch((input, init) => - handler.fetch(new Request(input as string | URL, init)), - ); - const client = new Client({ name: "dual-era-fixture-test", version: "1.0.0" }); - const transport = new StreamableHTTPClientTransport( - new URL("http://fixture.local/mcp"), - { fetch: cap.fetch }, - ); - await client.connect(transport); - return { client, cap }; + return connectFixture(); } describe("dual-era fixture — modern (2026-07-28)", () => { @@ -71,7 +66,9 @@ describe("dual-era fixture — modern (2026-07-28)", () => { expect(tools.tools.map((t) => t.name)).toContain("echo"); const resources = await client.listResources(); - expect(resources.resources.map((r) => r.uri)).toContain(FIXTURE_GREETING_URI); + expect(resources.resources.map((r) => r.uri)).toContain( + FIXTURE_GREETING_URI + ); const prompts = await client.listPrompts(); expect(prompts.prompts.map((p) => p.name)).toContain("welcome"); @@ -94,16 +91,21 @@ describe("dual-era fixture — modern (2026-07-28)", () => { await client.listTools(); const readResult = await client.readResource({ uri: FIXTURE_GREETING_URI }); expect( - Array.isArray(readResult.contents) ? readResult.contents[0]?.text : undefined, + Array.isArray(readResult.contents) + ? readResult.contents[0]?.text + : undefined ).toBe("hello from the fixture"); // server/discover fired during connect (SEP-2575). expect(byMethod(cap.exchanges, "server/discover")).toBeDefined(); + expect(byMethod(cap.exchanges, "initialize")).toBeUndefined(); // resultType is REQUIRED on every modern result (SEP-2322). const list = byMethod(cap.exchanges, "tools/list"); expect(list, "tools/list exchange captured").toBeDefined(); - expect(getWireField(list!.response.json, "result.resultType")).toBe("complete"); + expect(getWireField(list!.response.json, "result.resultType")).toBe( + "complete" + ); // CacheableResult: ttlMs + cacheScope on list/read results (SEP-2549). The // server always emits both on the modern era (defaulting to 0 / 'private'). @@ -121,7 +123,7 @@ describe("dual-era fixture — modern (2026-07-28)", () => { "params", "_meta", "io.modelcontextprotocol/protocolVersion", - ]), + ]) ).toBe("2026-07-28"); // No sessions in the modern era (SEP-2567): the server must never mint one. @@ -133,6 +135,16 @@ describe("dual-era fixture — modern (2026-07-28)", () => { }); }); +describe("dual-era fixture — automatic selection", () => { + it("selects modern via server/discover without initialize", async () => { + const { client, cap } = await connectAutomatic(); + expect(client.getProtocolEra()).toBe("modern"); + expect(byMethod(cap.exchanges, "server/discover")).toBeDefined(); + expect(byMethod(cap.exchanges, "initialize")).toBeUndefined(); + await client.close(); + }); +}); + describe("dual-era fixture — legacy (2025-era)", () => { it("serves the same handler over the legacy era with no modern wire members", async () => { const { client, cap } = await connectLegacy(); @@ -140,6 +152,7 @@ describe("dual-era fixture — legacy (2025-era)", () => { const tools = await client.listTools(); expect(tools.tools.map((t) => t.name)).toContain("echo"); + expect(byMethod(cap.exchanges, "initialize")).toBeDefined(); // The legacy era carries no `resultType` on the wire — the modern-only // member must not leak onto a 2025-era result.