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
27 changed files with 808 additions and 16 deletions

View file

@ -220,6 +220,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
- Follow TypeScript best practices and coding standards
- NEVER use 'any' as a type
- NEVER suppress error types (e.g., no-unused-vars, no-explicit-any) in the typescript or eslint configuration as a way of satisfying the linter or compiler.
- AVOID double casts (`as unknown as T`). They erase all type safety and usually signal that the real type is being worked around. Prefer a type guard, a narrower single `as` cast, or fixing the underlying type. When a double cast is genuinely unavoidable (e.g. a documented gap in a third-party type, or bridging a structurally-identical shape TS can't relate), it MUST carry an inline comment justifying why it is safe and why no better option exists — an unjustified `as unknown as` is not acceptable in review.
- Utilize type annotations and interfaces to improve code clarity and maintainability
- Leverage TypeScript's type inference and static analysis features for better code quality and refactoring
- Use type guards and type assertions to handle potential type mismatches and ensure type safety

View file

@ -9,6 +9,7 @@ import type {
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
eraToVersionNegotiation,
} from "@inspector/core/mcp/types.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import {
@ -162,6 +163,11 @@ async function callMethod(
sample: false,
elicit: false,
serverSettings,
// Per-server protocol era (SEP §7.8) from mcp.json → SDK versionNegotiation.
// Absent era defaults to legacy in the InspectorClient constructor (#1626).
...(serverSettings?.protocolEra && {
versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra),
}),
...clientAuthOptions,
});

View file

@ -20,6 +20,7 @@ import type {
GetPromptResult,
} from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import {
ManagedToolsState,
ManagedResourcesState,
@ -303,6 +304,13 @@ function App({
defaultMetadata,
}),
...(savedSettings && { serverSettings: savedSettings }),
// Per-server protocol era (SEP §7.8) from mcp.json → SDK
// versionNegotiation; absent era defaults to legacy (#1626).
...(savedSettings?.protocolEra && {
versionNegotiation: eraToVersionNegotiation(
savedSettings.protocolEra,
),
}),
...clientAuthOptions,
};
if (isOAuthCapableServerConfig(serverConfig)) {

View file

@ -43,6 +43,7 @@ import type {
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
eraToVersionNegotiation,
} from "@inspector/core/mcp/types.js";
import {
API_SERVER_ENV_VARS,
@ -905,6 +906,8 @@ function App() {
serverInfo,
instructions,
protocolVersion,
protocolEra,
discoverResult,
lastError,
} = useInspectorClient(inspectorClient);
const {
@ -2137,6 +2140,14 @@ function App() {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
}),
...(savedSettings && { serverSettings: savedSettings }),
// Per-server protocol era (SEP §7.8) → SDK versionNegotiation. Absent
// settings or an unset era default to legacy inside
// eraToVersionNegotiation / the InspectorClient constructor (#1626).
...(savedSettings?.protocolEra && {
versionNegotiation: eraToVersionNegotiation(
savedSettings.protocolEra,
),
}),
// Set on the `/oauth/callback` rebuild so the client's `saveSession`
// events (and any later persistence) key off the same OAuth authId
// the pre-redirect page saved under.
@ -4042,6 +4053,8 @@ function App() {
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport={connectionInfoTransport}
protocolEra={protocolEra}
discoverResult={discoverResult}
oauth={connectionInfoOAuth}
onClearOAuth={
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined

View file

@ -42,6 +42,33 @@ export const FullCapabilities: Story = {
},
};
export const ModernEra: Story = {
args: {
initializeResult: {
protocolVersion: "2026-07-28",
serverInfo: { name: "Modern Server", version: "2.0.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true },
},
},
clientCapabilities: fullClientCaps,
transport: "streamable-http",
protocolEra: "modern",
discoverResult: {
supportedVersions: ["2026-07-28", "2025-11-25"],
serverInfo: { name: "Modern Server", version: "2.0.0" },
capabilities: {
tools: { listChanged: true },
resources: { subscribe: true },
extensions: {
"io.modelcontextprotocol/tasks": {},
},
},
},
},
};
export const MinimalCapabilities: Story = {
args: {
initializeResult: {

View file

@ -99,6 +99,83 @@ describe("ConnectionInfoContent", () => {
expect(screen.queryByText("Server Instructions")).not.toBeInTheDocument();
});
it("defaults to the Legacy era, and marks the session N/A for stdio", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="stdio"
/>,
);
expect(screen.getByText("Era")).toBeInTheDocument();
expect(screen.getByText("Legacy")).toBeInTheDocument();
// stdio has no HTTP session concept.
expect(screen.getByText("N/A (stdio)")).toBeInTheDocument();
// No discover result → no Discovery section.
expect(screen.queryByText("Discovery")).not.toBeInTheDocument();
});
it("marks a legacy HTTP connection as session-based", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="legacy"
/>,
);
expect(screen.getByText("Legacy")).toBeInTheDocument();
expect(screen.getByText("Session-based")).toBeInTheDocument();
});
it("shows the Modern era as sessionless and renders the discovery section", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="modern"
discoverResult={{
supportedVersions: ["2026-07-28", "2025-11-25"],
serverInfo: { name: "Everything Server", version: "2.1.0" },
capabilities: {
tools: {},
extensions: {
"io.modelcontextprotocol/tasks": {},
},
},
}}
/>,
);
expect(screen.getByText("Modern")).toBeInTheDocument();
expect(screen.getByText("Sessionless")).toBeInTheDocument();
expect(screen.getByText("Discovery")).toBeInTheDocument();
expect(screen.getByText("2026-07-28, 2025-11-25")).toBeInTheDocument();
expect(
screen.getByText("io.modelcontextprotocol/tasks"),
).toBeInTheDocument();
});
it("renders em-dashes for empty supported versions and absent extensions", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
protocolEra="modern"
discoverResult={{
supportedVersions: [],
serverInfo: { name: "Everything Server", version: "2.1.0" },
capabilities: { tools: {} },
}}
/>,
);
expect(screen.getByText("Supported versions")).toBeInTheDocument();
expect(screen.getByText("Extensions")).toBeInTheDocument();
// Both the empty-versions and no-extensions values render as an em dash.
expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2);
});
it("renders client registration kind when provided", () => {
renderWithMantine(
<ConnectionInfoContent

View file

@ -11,7 +11,9 @@ import {
} from "@mantine/core";
import type {
ClientCapabilities,
DiscoverResult,
InitializeResult,
ProtocolEra,
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
@ -38,6 +40,18 @@ export interface ConnectionInfoContentProps {
initializeResult: InitializeResult;
clientCapabilities: ClientCapabilities;
transport: ServerType;
/**
* Protocol era negotiated with the server (SEP §7.8). `"modern"` connections
* are sessionless and learn capabilities from `server/discover`; `"legacy"`
* (or undefined, on a plain legacy connect) use the initialize handshake.
* (#1626)
*/
protocolEra?: ProtocolEra;
/**
* The `server/discover` result on a modern connection — supported versions,
* capabilities, and extensions learned up front. Undefined on legacy. (#1626)
*/
discoverResult?: DiscoverResult;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
@ -97,6 +111,38 @@ function formatClientRegistrationKind(
}
}
// The SDK reports the era for every connected era, including a plain legacy
// connect (`"legacy"`); it's undefined only when not connected. Anything other
// than `"modern"` renders as the legacy era.
function isModernEra(era: ProtocolEra | undefined): boolean {
return era === "modern";
}
function formatEra(era: ProtocolEra | undefined): string {
return isModernEra(era) ? "Modern" : "Legacy";
}
// The session concept is HTTP-only: modern HTTP connections are sessionless (no
// `Mcp-Session-Id`, nothing to DELETE on disconnect) while a legacy HTTP
// connection may carry a server session. stdio has no HTTP session at all, so
// the row is not applicable there.
function formatSession(
era: ProtocolEra | undefined,
transport: ServerType,
): string {
if (transport === "stdio") return "N/A (stdio)";
return isModernEra(era) ? "Sessionless" : "Session-based";
}
// The `extensions` map the server advertised in its `server/discover`
// capabilities (SEP-2133). Render the extension identifiers, or an em dash when
// none were advertised.
function formatExtensions(discoverResult: DiscoverResult): string {
const extensions = discoverResult.capabilities.extensions;
const keys = extensions ? Object.keys(extensions) : [];
return keys.length > 0 ? keys.join(", ") : "—";
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
@ -131,6 +177,8 @@ export function ConnectionInfoContent({
initializeResult,
clientCapabilities,
transport,
protocolEra,
discoverResult,
oauth,
onClearOAuth,
}: ConnectionInfoContentProps) {
@ -159,9 +207,37 @@ export function ConnectionInfoContent({
<Text size="sm">Transport</Text>
<Badge variant="outline">{transport}</Badge>
<Text size="sm">Era</Text>
<Badge
variant="outline"
color={isModernEra(protocolEra) ? "blue" : "gray"}
>
{formatEra(protocolEra)}
</Badge>
<Text size="sm">Session</Text>
<ValueText>{formatSession(protocolEra, transport)}</ValueText>
</SimpleGrid>
</Stack>
{discoverResult && (
<Stack gap="xs">
<SectionHeading>Discovery</SectionHeading>
<SimpleGrid cols={2}>
<Text size="sm">Supported versions</Text>
<ValueText>
{discoverResult.supportedVersions.length > 0
? discoverResult.supportedVersions.join(", ")
: "—"}
</ValueText>
<Text size="sm">Extensions</Text>
<ValueText>{formatExtensions(discoverResult)}</ValueText>
</SimpleGrid>
</Stack>
)}
<SimpleGrid cols={2}>
<Stack gap="xs">
<SectionHeading>Server Capabilities</SectionHeading>

View file

@ -1,7 +1,9 @@
import { CloseButton, Group, Modal, Stack } from "@mantine/core";
import type {
ClientCapabilities,
DiscoverResult,
InitializeResult,
ProtocolEra,
} from "@modelcontextprotocol/client";
import type { ServerType } from "@inspector/core/mcp/types.js";
import {
@ -15,6 +17,8 @@ export interface ConnectionInfoModalProps {
initializeResult: InitializeResult;
clientCapabilities: ClientCapabilities;
transport: ServerType;
protocolEra?: ProtocolEra;
discoverResult?: DiscoverResult;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
@ -25,6 +29,8 @@ export function ConnectionInfoModal({
initializeResult,
clientCapabilities,
transport,
protocolEra,
discoverResult,
oauth,
onClearOAuth,
}: ConnectionInfoModalProps) {
@ -47,6 +53,8 @@ export function ConnectionInfoModal({
initializeResult={initializeResult}
clientCapabilities={clientCapabilities}
transport={transport}
protocolEra={protocolEra}
discoverResult={discoverResult}
oauth={oauth}
onClearOAuth={onClearOAuth}
/>

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."

View file

@ -118,6 +118,27 @@ describe("ServerSettingsModal", () => {
);
});
it("maps the selected protocol era into settings (#1626)", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Options section (with Protocol Era) is expanded by default.
await user.click(screen.getByDisplayValue("Legacy (2025-11-25 handshake)"));
await user.click(screen.getByText("Modern (2026-07-28, sessionless)"));
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ protocolEra: "modern" }),
);
});
it("calls onSettingsChange when adding a header after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();

View file

@ -3,6 +3,7 @@ import { CloseButton, Group, Modal, Stack } from "@mantine/core";
import type {
InspectorServerSettings,
OAuthSettings,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
@ -165,6 +166,10 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, maxFetchRequests: value });
}
function handleProtocolEraChange(value: ServerProtocolEra) {
onSettingsChange({ ...settings, protocolEra: value });
}
function handleAddRoot() {
onSettingsChange({
...settings,
@ -228,6 +233,7 @@ export function ServerSettingsModal({
onTimeoutChange={handleTimeoutChange}
onAutoRefreshChange={handleAutoRefreshChange}
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
onProtocolEraChange={handleProtocolEraChange}
onOAuthChange={handleOAuthChange}
onClearStoredOAuth={onClearStoredOAuth}
onAddRoot={handleAddRoot}

View file

@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_PROTOCOL_ERA,
MODERN_PROTOCOL_VERSION,
eraToVersionNegotiation,
} from "@inspector/core/mcp/types.js";
describe("eraToVersionNegotiation", () => {
it("maps legacy to the legacy mode", () => {
expect(eraToVersionNegotiation("legacy")).toEqual({ mode: "legacy" });
});
it("maps auto to the probing mode", () => {
expect(eraToVersionNegotiation("auto")).toEqual({ mode: "auto" });
});
it("maps modern to a pin at the modern protocol version", () => {
expect(eraToVersionNegotiation("modern")).toEqual({
mode: { pin: MODERN_PROTOCOL_VERSION },
});
});
it("defaults to the legacy era", () => {
expect(DEFAULT_PROTOCOL_ERA).toBe("legacy");
expect(eraToVersionNegotiation(DEFAULT_PROTOCOL_ERA)).toEqual({
mode: "legacy",
});
});
});

View file

@ -303,6 +303,76 @@ describe("serverEntriesToMcpConfig", () => {
expect(round).toEqual(original);
});
it("round-trips protocolEra: lifts a non-default value to settings and back to disk", () => {
const original: MCPConfig = {
mcpServers: {
"era-modern": {
type: "streamable-http",
url: "https://x.test/mcp",
protocolEra: "modern",
},
},
};
const [entry] = mcpConfigToServerEntries(original);
expect(entry?.settings?.protocolEra).toBe("modern");
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
expect(round).toEqual(original);
});
it("round-trips protocolEra: auto is a non-default value", () => {
const original: MCPConfig = {
mcpServers: {
"era-auto": {
type: "streamable-http",
url: "https://x.test/mcp",
protocolEra: "auto",
},
},
};
const [entry] = mcpConfigToServerEntries(original);
expect(entry?.settings?.protocolEra).toBe("auto");
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
expect(round).toEqual(original);
});
it("drops an unknown protocolEra literal on read (hand-edited file)", () => {
// The CLI/TUI read mcp.json directly (no /api/servers validators), so a
// garbage era must be dropped here rather than reaching versionNegotiation.
// Spread the invalid field through an `object`-typed literal so the garbage
// value models a hand-edited file without needing an `as unknown as` cast.
const badEra: object = { protocolEra: "future" };
const original: MCPConfig = {
mcpServers: {
"era-bad": {
type: "streamable-http",
url: "https://x.test/mcp",
...badEra,
},
},
};
const [entry] = mcpConfigToServerEntries(original);
expect(entry?.settings?.protocolEra).toBeUndefined();
});
it("omits protocolEra from disk when it equals the default (legacy)", () => {
// A legacy era is the default — writing it back must NOT inject the field.
// A benign inspector field keeps `settings` materialized.
const original: MCPConfig = {
mcpServers: {
"era-legacy": {
type: "streamable-http",
url: "https://x.test/mcp",
protocolEra: "legacy",
connectionTimeout: 5000,
},
},
};
const [entry] = mcpConfigToServerEntries(original);
expect(entry?.settings?.protocolEra).toBe("legacy");
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
expect("protocolEra" in (round.mcpServers["era-legacy"] ?? {})).toBe(false);
});
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
const cfg: MCPConfig = {
mcpServers: {

View file

@ -77,6 +77,59 @@ describe("useInspectorClient", () => {
expect(result.current.protocolVersion).toBe("2025-06-18");
});
it("returns initial protocolEra / discoverResult from the client", () => {
const discoverResult = {
supportedVersions: ["2026-07-28"],
serverInfo: SERVER_INFO,
capabilities: {},
};
const client = new FakeInspectorClient({
status: "connected",
protocolEra: "modern",
discoverResult,
});
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.protocolEra).toBe("modern");
expect(result.current.discoverResult).toEqual(discoverResult);
});
it("subscribes to protocolEraChange and discoverResultChange", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.protocolEra).toBeUndefined();
expect(result.current.discoverResult).toBeUndefined();
const discoverResult = {
supportedVersions: ["2026-07-28"],
serverInfo: SERVER_INFO,
capabilities: {},
};
act(() => {
client.setProtocolEra("modern");
client.setDiscoverResult(discoverResult);
});
expect(result.current.protocolEra).toBe("modern");
expect(result.current.discoverResult).toEqual(discoverResult);
});
it("resets protocolEra / discoverResult to defaults when client becomes null", () => {
const client = new FakeInspectorClient({
status: "connected",
protocolEra: "modern",
discoverResult: {
supportedVersions: ["2026-07-28"],
serverInfo: SERVER_INFO,
capabilities: {},
},
});
const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), {
initialProps: { c: client as FakeInspectorClient | null },
});
expect(result.current.protocolEra).toBe("modern");
rerender({ c: null });
expect(result.current.protocolEra).toBeUndefined();
expect(result.current.discoverResult).toBeUndefined();
});
it("connect() and disconnect() proxy to the client and update status", async () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));

View file

@ -746,12 +746,44 @@ describe("InspectorClient", () => {
expect(client.getProtocolVersion()).toBeUndefined();
await client.connect();
// stdio transports don't store the version themselves; the capture
// happens in MessageTrackingTransport, so this also guards that path.
// The SDK Client's getNegotiatedProtocolVersion() supplies the version
// for both eras.
expect(client.getProtocolVersion()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// A legacy connect reports the legacy era; there is no server/discover
// result without a probe.
expect(client.getProtocolEra()).toBe("legacy");
expect(client.getDiscoverResult()).toBeUndefined();
await client.disconnect();
expect(client.getProtocolVersion()).toBeUndefined();
expect(client.getProtocolEra()).toBeUndefined();
expect(client.getDiscoverResult()).toBeUndefined();
});
it("negotiates the legacy era under versionNegotiation 'auto' against a legacy server (stdio)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{
environment: { transport: createTransportNode },
// Auto probes server/discover, then falls back to the initialize
// handshake on this legacy test server — so the negotiated era is
// legacy and getProtocolEra() reports it (vs. undefined on a plain
// legacy connect).
versionNegotiation: { mode: "auto" },
},
);
await client.connect();
expect(client.getProtocolEra()).toBe("legacy");
expect(client.getProtocolVersion()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// A legacy server has no server/discover result even when probed.
expect(client.getDiscoverResult()).toBeUndefined();
await client.disconnect();
});
it("should not auto-fetch server contents when disabled", async () => {

View file

@ -424,6 +424,11 @@ describe("server.ts supplemental coverage", () => {
expect((await res.json()).error).toMatch(/roots/);
});
it("rejects an unknown protocolEra", async () => {
const res = await postSettings({ ...base, protocolEra: "future" });
expect((await res.json()).error).toMatch(/protocolEra/);
});
it("accepts a fully-populated valid settings payload", async () => {
const res = await postSettings({
...base,
@ -432,6 +437,7 @@ describe("server.ts supplemental coverage", () => {
taskTtl: 1000,
autoRefreshOnListChanged: true,
maxFetchRequests: 5,
protocolEra: "modern",
oauthClientId: "cid",
oauthScopes: "a b",
enterpriseManaged: true,
@ -467,6 +473,8 @@ describe("server.ts supplemental coverage", () => {
requestTimeout: -1,
taskTtl: "x",
maxFetchRequests: -1,
// unknown era literal → isProtocolEra branch
protocolEra: "future",
// enterpriseManaged non-boolean → isOauthObject inner branch
oauth: { enterpriseManaged: "yes" },
// a non-string `name` → isRootArray inner branch
@ -494,6 +502,7 @@ describe("server.ts supplemental coverage", () => {
"requestTimeout",
"taskTtl",
"maxFetchRequests",
"protocolEra",
"oauth",
"roots",
]) {

View file

@ -1045,6 +1045,8 @@ describe("/api/servers routes", () => {
connectionTimeout: "30s",
// Good: valid number
requestTimeout: 60000,
// Good: valid era literal → round-trips
protocolEra: "modern",
// Bad: array instead of object → drop
oauth: ["not", "an", "object"],
// Bad: entry missing a string `uri` → drop the whole roots field
@ -1063,6 +1065,7 @@ describe("/api/servers routes", () => {
// Good fields survive.
expect(fetched.headers).toEqual({ "X-Keep": "yes" });
expect(fetched.requestTimeout).toBe(60000);
expect(fetched.protocolEra).toBe("modern");
// Bad fields are dropped.
expect(fetched).not.toHaveProperty("metadata");
expect(fetched).not.toHaveProperty("connectionTimeout");

View file

@ -8,9 +8,11 @@
import { vi } from "vitest";
import type {
ClientCapabilities,
DiscoverResult,
Implementation,
LoggingLevel,
Prompt,
ProtocolEra,
Resource,
ResourceTemplateType as ResourceTemplate,
ServerCapabilities,
@ -45,6 +47,8 @@ export interface FakeInspectorClientOptions {
serverInfo?: Implementation;
instructions?: string;
protocolVersion?: string;
protocolEra?: ProtocolEra;
discoverResult?: DiscoverResult;
serverSettings?: InspectorServerSettings;
}
@ -58,6 +62,8 @@ export class FakeInspectorClient
private serverInfo: Implementation | undefined;
private instructions: string | undefined;
private protocolVersion: string | undefined;
private protocolEra: ProtocolEra | undefined;
private discoverResult: DiscoverResult | undefined;
private serverSettings: InspectorServerSettings | undefined;
private appRendererClient: AppRendererClient | null = null;
private sessionId: string | undefined;
@ -153,6 +159,8 @@ export class FakeInspectorClient
this.serverInfo = options.serverInfo;
this.instructions = options.instructions;
this.protocolVersion = options.protocolVersion;
this.protocolEra = options.protocolEra;
this.discoverResult = options.discoverResult;
this.serverSettings = options.serverSettings;
}
@ -180,6 +188,14 @@ export class FakeInspectorClient
return this.protocolVersion;
}
getProtocolEra(): ProtocolEra | undefined {
return this.protocolEra;
}
getDiscoverResult(): DiscoverResult | undefined {
return this.discoverResult;
}
getServerSettings(): InspectorServerSettings | undefined {
return this.serverSettings;
}
@ -259,6 +275,16 @@ export class FakeInspectorClient
this.dispatchTypedEvent("protocolVersionChange", protocolVersion);
}
setProtocolEra(protocolEra: ProtocolEra | undefined): void {
this.protocolEra = protocolEra;
this.dispatchTypedEvent("protocolEraChange", protocolEra);
}
setDiscoverResult(discoverResult: DiscoverResult | undefined): void {
this.discoverResult = discoverResult;
this.dispatchTypedEvent("discoverResultChange", discoverResult);
}
setAppRendererClient(client: AppRendererClient | null): void {
this.appRendererClient = client;
}

Some files were not shown because too many files have changed in this diff Show more