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
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:
parent
7fecebed5e
commit
e026051b47
27 changed files with 808 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
29
clients/web/src/test/core/mcp/protocolEra.test.ts
Normal file
29
clients/web/src/test/core/mcp/protocolEra.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue