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.