Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -124,23 +123,20 @@ 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
* on those clients produced choices that could only fail at Save with an
* 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.
Expand Down Expand Up @@ -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"`,
Expand Down Expand Up @@ -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)
) {
Expand Down Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -736,7 +730,7 @@ export function ProtocolTab({
const updated: HostConfigMcpProfileV1 = {
...base,
initialize,
mcpProtocolVersion: next,
mcpProtocolVersion: next ?? "auto",
};
return {
...prev,
Expand Down Expand Up @@ -870,7 +864,7 @@ export function ProtocolTab({
<div className="flex items-center gap-3">
<span
className="text-[12px] font-medium"
title="Automatic: store no pin — MCPJam picks the wire version at connect time. Any other choice pins that exact revision for every server on this client."
title="Automatic: negotiate at connect time. Any other choice pins that exact revision for every server on this client."
>
{fProtocolVersion.label}
</span>
Expand Down Expand Up @@ -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" && (
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
Expand All @@ -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 && (
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
This client advertises{" "}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Harness initial={emptyHostConfigInputV2()} />);

expect(
Expand All @@ -127,7 +127,7 @@ describe("ProtocolTab protocol-version dropdown", () => {
expect(screen.getByTestId("pin").textContent).toBe("<undefined>");
});

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(<Harness initial={emptyHostConfigInputV2()} />);

Expand All @@ -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("<undefined>");
expect(screen.getByTestId("pin").textContent).toBe("auto");
});

it("shows a stored legacy pin as itself instead of collapsing it", () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"]);
Expand Down Expand Up @@ -390,19 +386,16 @@ 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(
<Harness initial={withAdvertised(["2025-11-25"], "2025-11-25")} />
);
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(<Harness initial={withAdvertised(["2025-11-25"], "2026-07-28")} />);
expect(screen.queryByText(/does not advertise/)).toBeNull();
expect(screen.getByText(/does not advertise/)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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<HostConfigInputV2> = { mcpProfile: source };
const input = emptyHostConfigInputV2(partial);
expect(input.mcpProfile).toEqual(SAMPLE_PROFILE);
// Mutate the source — input must be unaffected.
(source.initialize!.clientInfo as Record<string, unknown>).name =
"mutated";
(source.initialize!.clientInfo as Record<string, unknown>).name = "mutated";
expect(input.mcpProfile?.initialize?.clientInfo?.name).toBe("chatgpt");
});
});
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
);
});

Expand All @@ -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"
);
});
});

Expand All @@ -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 },
]) {
Expand All @@ -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);
});

Expand Down
5 changes: 3 additions & 2 deletions mcpjam-inspector/client/src/lib/host-config-field-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,15 +471,16 @@ export const HOST_CONFIG_FIELDS: ReadonlyArray<HostConfigFieldDef> = [
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<McpProtocolVersion>,
] as ReadonlyArray<McpProtocolVersion | "auto">,
},
read: (cfg) => mcpProfile(cfg)?.mcpProtocolVersion,
},
Expand Down
Loading
Loading