Era-aware connection model & capability gating (#1626) (#1697)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

* Era-aware connection model & capability gating (#1626)

Make protocol era (SEP §7.8) a first-class, per-server connection dimension,
orthogonal to the transport type, and drive the SDK Client's versionNegotiation
from it.

Config
- Add `ServerProtocolEra` ("legacy" | "auto" | "modern"), `DEFAULT_PROTOCOL_ERA`
  (legacy, per the SDK's don't-auto-probe guidance), `MODERN_PROTOCOL_VERSION`,
  and `eraToVersionNegotiation()` in core/mcp/types.ts.
- Persist `protocolEra` on `StoredMCPServer` / `InspectorServerSettings`,
  round-tripping through serverList.ts (omitting the default on disk) and the
  `/api/servers` read + write validators.

InspectorClient / state
- Thread `versionNegotiation` through `InspectorClientOptions` (default legacy),
  replacing the hardcoded pin. After connect, read `getProtocolEra()` /
  `getDiscoverResult()` / `getNegotiatedProtocolVersion()`, expose them via
  getters, dispatch `protocolEraChange` / `discoverResultChange`, and clear on
  disconnect. `useInspectorClient` surfaces `protocolEra` / `discoverResult`.

Web UI
- ServerSettingsForm: a Protocol Era selector bound to the per-server setting.
- ConnectionInfoContent: era-aware — Era badge, Session ("Sessionless" on
  modern), and a Discovery section (supported versions + extensions) from the
  `server/discover` result.

CLI/TUI also map the mcp.json `protocolEra` into versionNegotiation so the field
applies uniformly. Version negotiation stays fully client-side; modern connect
works over the existing remote tunnel and disconnect is sessionless (the SDK
skips DELETE when there is no session id). Mcp-* header fidelity is left to the
History/Network card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Address @claude review: era docstrings, era validation, stdio session label, AGENTS.md rule

- Fix stale docstrings/comments in inspectorClient.ts, useInspectorClient.ts,
  inspectorClientEventTarget.ts, and ConnectionInfoContent.tsx: getProtocolEra()
  returns "legacy" (not undefined) on a plain legacy connect; it is undefined
  only when not connected.
- Validate protocolEra at the storedFieldsToInspectorSettings read boundary
  (new shared isProtocolEra guard) so a hand-edited mcp.json read directly by
  the CLI/TUI drops an unknown era instead of passing it to
  eraToVersionNegotiation. Keeps the exhaustive switch intact.
- ConnectionInfoContent: the Session row is HTTP-only — show "N/A (stdio)" for
  stdio instead of the misleading "Session-based".
- AGENTS.md: add a rule against unjustified `as unknown as` double casts, and
  drop the one such cast introduced in the serverList test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Address re-review: dedupe isProtocolEra guard in remote server

Replace the local isProtocolEra in remote/node/server.ts with the shared guard
now exported from serverList.ts (already imported here), so the era literal set
lives in one place and can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-16 23:39:29 -04:00 • committed by GitHub
commit e026051b47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 808 additions and 16 deletions

View file

@ -137,6 +137,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
settings: { ...args.settings, maxFetchRequests: value },
});
}}
onProtocolEraChange={(value) => {
args.onProtocolEraChange(value);
updateArgs({
settings: { ...args.settings, protocolEra: value },
});
}}
onOAuthChange={(oauth) => {
args.onOAuthChange(oauth);
updateArgs({
@ -173,6 +179,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
onMaxFetchRequestsChange: fn(),
onProtocolEraChange: fn(),
onOAuthChange: fn(),
},
};

View file

@ -66,6 +66,7 @@ const baseHandlers = {
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
onMaxFetchRequestsChange: vi.fn(),
onProtocolEraChange: vi.fn(),
onOAuthChange: vi.fn(),
onAddRoot: vi.fn(),
onRemoveRoot: vi.fn(),
@ -88,6 +89,46 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText("Roots")).toBeInTheDocument();
});
it("defaults the Protocol Era select to Legacy when unset", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["options"]}
/>,
);
expect(
screen.getByDisplayValue("Legacy (2025-11-25 handshake)"),
).toBeInTheDocument();
});
it("invokes onProtocolEraChange with the selected era", async () => {
const user = userEvent.setup();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
expandedSections={["options"]}
/>,
);
await user.click(screen.getByDisplayValue("Legacy (2025-11-25 handshake)"));
await user.click(screen.getByText("Modern (2026-07-28, sessionless)"));
expect(baseHandlers.onProtocolEraChange).toHaveBeenCalledWith("modern");
});
it("reflects the configured protocolEra in the select value", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{ ...emptySettings, protocolEra: "auto" }}
expandedSections={["options"]}
/>,
);
expect(
screen.getByDisplayValue("Auto (probe, fall back to legacy)"),
).toBeInTheDocument();
});
it("shows empty hints for headers and metadata when no entries exist", () => {
renderWithMantine(
<ServerSettingsForm

View file

@ -15,8 +15,10 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type {
InspectorServerSettings,
OAuthSettings,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
import { DEFAULT_PROTOCOL_ERA } from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import type { Root } from "@modelcontextprotocol/client";
@ -57,6 +59,7 @@ export interface ServerSettingsFormProps {
) => void;
onAutoRefreshChange: (value: boolean) => void;
onMaxFetchRequestsChange: (value: number) => void;
onProtocolEraChange: (value: ServerProtocolEra) => void;
onOAuthChange: (oauth: OAuthSettings) => void;
onClearStoredOAuth?: () => void;
onAddRoot: () => void;
@ -64,6 +67,23 @@ export interface ServerSettingsFormProps {
onRootChange: (index: number, uri: string, name: string) => void;
}
// Protocol-era options (SEP §7.8). Values are `ServerProtocolEra`; the pinned
// modern revision is an internal detail (MODERN_PROTOCOL_VERSION), so the label
// stays version-free.
const PROTOCOL_ERA_OPTIONS: { value: ServerProtocolEra; label: string }[] = [
{ value: "legacy", label: "Legacy (2025-11-25 handshake)" },
{ value: "auto", label: "Auto (probe, fall back to legacy)" },
{ value: "modern", label: "Modern (2026-07-28, sessionless)" },
];
const PROTOCOL_ERA_VALUES: ReadonlySet<ServerProtocolEra> = new Set(
PROTOCOL_ERA_OPTIONS.map((o) => o.value),
);
function isProtocolEra(value: string | null): value is ServerProtocolEra {
return value !== null && PROTOCOL_ERA_VALUES.has(value as ServerProtocolEra);
}
const RemoveIcon = ActionIcon.withProps({
color: "red",
variant: "subtle",
@ -220,6 +240,7 @@ export function ServerSettingsForm({
onTimeoutChange,
onAutoRefreshChange,
onMaxFetchRequestsChange,
onProtocolEraChange,
onOAuthChange,
onClearStoredOAuth,
onAddRoot,
@ -273,6 +294,20 @@ export function ServerSettingsForm({
<Accordion.Control>Options</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Select
label="Protocol Era"
description="Which MCP protocol era to negotiate with this server (independent of the transport). Legacy uses the 2025-11-25 initialize handshake; Auto probes server/discover and falls back to legacy; Modern pins the 2026-07-28 sessionless protocol. Defaults to Legacy — debugging tools should not auto-probe."
data={PROTOCOL_ERA_OPTIONS}
value={settings.protocolEra ?? DEFAULT_PROTOCOL_ERA}
onChange={(value) => {
// Select emits `string | null`; the null (cleared) case is
// unreachable here — the field is not clearable and the value
// always resolves to a known era.
/* v8 ignore next -- Select never emits an out-of-range value */
if (isProtocolEra(value)) onProtocolEraChange(value);
}}
allowDeselect={false}
/>
<Checkbox
label="Auto Refresh on List Changed Notifications"
description="When checked, tool/prompt/resource lists refresh automatically when the server sends a */list_changed notification. When unchecked, the list-changed indicator appears and you refresh on demand."