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
8 changed files with 808 additions and 16 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ import type {
|
|||
RequestOptions,
|
||||
ProgressCallback,
|
||||
VersionNegotiationOptions,
|
||||
ProtocolEra,
|
||||
DiscoverResult,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
|
|
@ -247,6 +249,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private requestTimeout: number | undefined;
|
||||
private defaultMetadata: Record<string, string> | undefined;
|
||||
private serverSettings: InspectorServerSettings | undefined;
|
||||
private versionNegotiation: VersionNegotiationOptions;
|
||||
private status: ConnectionStatus = "disconnected";
|
||||
// True only while an explicit disconnect() owns the teardown. close() can
|
||||
// trigger the transport's onclose synchronously, so this lets onclose defer
|
||||
|
|
@ -258,6 +261,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private serverInfo?: Implementation;
|
||||
private instructions?: string;
|
||||
private protocolVersion?: string;
|
||||
// Era model (SEP §7.8). Populated after connect from the SDK Client's
|
||||
// negotiation accessors. `protocolEra` is the negotiated era ("legacy" |
|
||||
// "modern"); `discoverResult` is the `server/discover` payload on a
|
||||
// probed/pinned connection (undefined on a plain legacy connect).
|
||||
private protocolEra?: ProtocolEra;
|
||||
private discoverResult?: DiscoverResult;
|
||||
// The capabilities this Inspector client advertises to the server during the
|
||||
// initialize handshake. Built once in setupClient() and snapshotted here so
|
||||
// UI surfaces (Server Info modal) can display them without poking at the
|
||||
|
|
@ -341,6 +350,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
? options.defaultMetadata
|
||||
: undefined;
|
||||
this.serverSettings = options.serverSettings;
|
||||
// Default to the legacy 2025-11-25 era when the caller doesn't pin one, per
|
||||
// the SDK guidance that a debugging tool must not auto-probe (#1626).
|
||||
this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" };
|
||||
this.directAuthRecovery = options.directAuthRecovery ?? false;
|
||||
// Only set roots if explicitly provided (even if empty array) - this enables roots capability
|
||||
this.roots = options.roots;
|
||||
|
|
@ -392,10 +404,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
capabilities?: ClientCapabilities;
|
||||
versionNegotiation?: VersionNegotiationOptions;
|
||||
} = {
|
||||
// Pin to the legacy (2025-11-25) protocol so this migration stays
|
||||
// behavior-neutral: nothing 2026-era goes on the wire. Era/version
|
||||
// selection becomes per-server configuration in a later card.
|
||||
versionNegotiation: { mode: "legacy" },
|
||||
// Per-server protocol era (SEP §7.8), threaded from config via
|
||||
// `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the
|
||||
// constructor. "legacy" keeps the wire byte-identical to a 2025 client;
|
||||
// "auto"/"modern" opt into 2026-era negotiation (#1626).
|
||||
versionNegotiation: this.versionNegotiation,
|
||||
};
|
||||
const capabilities: ClientCapabilities = {};
|
||||
if (this.sample) {
|
||||
|
|
@ -1450,6 +1463,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.serverInfo = undefined;
|
||||
this.instructions = undefined;
|
||||
this.protocolVersion = undefined;
|
||||
this.protocolEra = undefined;
|
||||
this.discoverResult = undefined;
|
||||
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
|
||||
this.dispatchTypedEvent(
|
||||
"pendingElicitationsChange",
|
||||
|
|
@ -1459,6 +1474,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
|
||||
this.dispatchTypedEvent("instructionsChange", this.instructions);
|
||||
this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion);
|
||||
this.dispatchTypedEvent("protocolEraChange", this.protocolEra);
|
||||
this.dispatchTypedEvent("discoverResultChange", this.discoverResult);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1757,13 +1774,38 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the MCP protocol version negotiated with the server during the
|
||||
* initialize handshake (e.g. "2025-06-18"). Undefined when not connected.
|
||||
* Get the MCP protocol version negotiated with the server. On a legacy
|
||||
* connect this is the version from the initialize handshake (e.g.
|
||||
* "2025-06-18"); on a modern connect it's the negotiated modern revision.
|
||||
* Undefined when not connected.
|
||||
*/
|
||||
getProtocolVersion(): string | undefined {
|
||||
return this.protocolVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* The protocol era negotiated with the server (SEP §7.8): `"legacy"` for the
|
||||
* 2025-11-25 initialize handshake, `"modern"` for the 2026-era sessionless
|
||||
* model. Populated for every era once connected — including a plain legacy
|
||||
* (`mode: "legacy"`) connect, which the SDK reports as `"legacy"`. Undefined
|
||||
* only when not connected (before connect / after disconnect). (#1626)
|
||||
*/
|
||||
getProtocolEra(): ProtocolEra | undefined {
|
||||
return this.protocolEra;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `server/discover` result captured on a probed (`"auto"`) or pinned
|
||||
* (`"modern"`) connect — server identity, capabilities, and supported
|
||||
* versions learned up front without an initialize handshake. Undefined when
|
||||
* not connected or on a plain legacy connect. Persistable and feedable back
|
||||
* to the SDK as `connect(transport, { prior })` for a zero-round-trip
|
||||
* reconnect. (#1626)
|
||||
*/
|
||||
getDiscoverResult(): DiscoverResult | undefined {
|
||||
return this.discoverResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-server settings this client was constructed with (headers,
|
||||
* timeouts, roots, OAuth, the auto-refresh-on-list-changed option, etc.).
|
||||
|
|
@ -2914,13 +2956,20 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.dispatchTypedEvent("instructionsChange", this.instructions);
|
||||
}
|
||||
|
||||
// The negotiated protocol version isn't exposed by the SDK Client; it's
|
||||
// stamped onto the transport during initialize. MessageTrackingTransport
|
||||
// captures it (see its setProtocolVersion) so we can surface it here.
|
||||
if (this.transport instanceof MessageTrackingTransport) {
|
||||
this.protocolVersion = this.transport.protocolVersion;
|
||||
this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion);
|
||||
}
|
||||
// Era model (SEP §7.8): the SDK Client owns negotiation and exposes the
|
||||
// outcome. `getProtocolEra()` is populated for every era once connected —
|
||||
// a plain legacy connect reports `"legacy"`. `getDiscoverResult()` is
|
||||
// populated only when "auto"/"modern" actually probed server/discover.
|
||||
this.protocolEra = this.client.getProtocolEra();
|
||||
this.dispatchTypedEvent("protocolEraChange", this.protocolEra);
|
||||
this.discoverResult = this.client.getDiscoverResult();
|
||||
this.dispatchTypedEvent("discoverResultChange", this.discoverResult);
|
||||
|
||||
// The SDK's negotiated-version accessor works for both eras (the
|
||||
// initialize handshake on legacy, the discover/pin on modern), so it
|
||||
// supersedes the older MessageTrackingTransport capture.
|
||||
this.protocolVersion = this.client.getNegotiatedProtocolVersion();
|
||||
this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion);
|
||||
} catch {
|
||||
// Ignore errors in fetching server info
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type {
|
|||
Task,
|
||||
CallToolResult,
|
||||
ProtocolError,
|
||||
ProtocolEra,
|
||||
DiscoverResult,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
|
||||
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
|
||||
|
|
@ -56,6 +58,10 @@ export interface InspectorClientEventMap {
|
|||
serverInfoChange: Implementation | undefined;
|
||||
instructionsChange: string | undefined;
|
||||
protocolVersionChange: string | undefined;
|
||||
/** Protocol era negotiated after connect (SEP §7.8); `"legacy"` on a legacy connect, undefined when not connected. */
|
||||
protocolEraChange: ProtocolEra | undefined;
|
||||
/** `server/discover` result on a probed/pinned connect; undefined on legacy. */
|
||||
discoverResultChange: DiscoverResult | undefined;
|
||||
message: MessageEntry;
|
||||
stderrLog: StderrLogEntry;
|
||||
fetchRequest: FetchRequestEntry;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import type {
|
|||
} from "./types.js";
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
DiscoverResult,
|
||||
Implementation,
|
||||
LoggingLevel,
|
||||
Prompt,
|
||||
ProtocolEra,
|
||||
Resource,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
ServerCapabilities,
|
||||
|
|
@ -56,6 +58,8 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
getServerInfo(): Implementation | undefined;
|
||||
getInstructions(): string | undefined;
|
||||
getProtocolVersion(): string | undefined;
|
||||
getProtocolEra(): ProtocolEra | undefined;
|
||||
getDiscoverResult(): DiscoverResult | undefined;
|
||||
getServerSettings(): InspectorServerSettings | undefined;
|
||||
setServerSettings(settings: InspectorServerSettings): void;
|
||||
getAppRendererClient(): AppRendererClient | null;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
extractSecretsFromStored,
|
||||
INSPECTOR_FIELD_KEYS,
|
||||
inspectorSettingsToStoredFields,
|
||||
isProtocolEra,
|
||||
mergeSecretsIntoStored,
|
||||
normalizeServerType,
|
||||
storedFieldsToInspectorSettings,
|
||||
|
|
@ -1178,6 +1179,13 @@ export function createRemoteApp(
|
|||
);
|
||||
delete valObj.roots;
|
||||
}
|
||||
if ("protocolEra" in valObj && !isProtocolEra(valObj.protocolEra)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "protocolEra" },
|
||||
"Dropping malformed `protocolEra` field — expected 'legacy', 'auto', or 'modern'.",
|
||||
);
|
||||
delete valObj.protocolEra;
|
||||
}
|
||||
|
||||
out[id] = normalizeServerType(
|
||||
valObj as Record<string, unknown> & { type?: string },
|
||||
|
|
@ -1436,6 +1444,15 @@ export function createRemoteApp(
|
|||
error: "settings.roots must be an array of { uri, name? }",
|
||||
};
|
||||
}
|
||||
// protocolEra is optional on the wire (older clients won't send it); when
|
||||
// present it must be one of the three era literals, otherwise it defaults
|
||||
// to absent (→ legacy) below.
|
||||
if (obj.protocolEra !== undefined && !isProtocolEra(obj.protocolEra)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.protocolEra must be 'legacy', 'auto', or 'modern'",
|
||||
};
|
||||
}
|
||||
// Build the validated value from explicitly named fields rather than
|
||||
// casting the raw object through. Unknown keys silently drop so a
|
||||
// misconfigured client can't smuggle stowaways onto disk, and consumers
|
||||
|
|
@ -1494,6 +1511,11 @@ export function createRemoteApp(
|
|||
) {
|
||||
value.oauthOnInsufficientScope = obj.oauthOnInsufficientScope;
|
||||
}
|
||||
// Optional; when a valid era is present, carry it. Absence reads back as the
|
||||
// default era downstream (eraToVersionNegotiation is not called for absent).
|
||||
if (isProtocolEra(obj.protocolEra)) {
|
||||
value.protocolEra = obj.protocolEra;
|
||||
}
|
||||
// Empty cwd coerces to absent on the value (matching the read side); the
|
||||
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
|
||||
// (preserve) via `cwdProvided` below rather than the value alone.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@
|
|||
* remote-server route handlers.
|
||||
*/
|
||||
|
||||
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "./types.js";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_PROTOCOL_ERA,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
} from "./types.js";
|
||||
import type { Root } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
StdioServerConfig,
|
||||
StoredMCPServer,
|
||||
|
|
@ -29,6 +34,27 @@ const VALID_SERVER_TYPES: ReadonlySet<ServerType> = new Set([
|
|||
"streamable-http",
|
||||
]);
|
||||
|
||||
const VALID_PROTOCOL_ERAS: ReadonlySet<ServerProtocolEra> = new Set([
|
||||
"legacy",
|
||||
"auto",
|
||||
"modern",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Runtime guard for the `protocolEra` literal. `StoredMCPServer` types the
|
||||
* field as `ServerProtocolEra`, but a hand-edited `mcp.json` read directly by
|
||||
* the CLI/TUI (which don't pass through the `/api/servers` route validators)
|
||||
* can carry any string. Dropping unknowns here keeps a garbage value from
|
||||
* reaching `eraToVersionNegotiation` — matching the read-side check the
|
||||
* `/api/servers` route already applies.
|
||||
*/
|
||||
export function isProtocolEra(value: unknown): value is ServerProtocolEra {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
VALID_PROTOCOL_ERAS.has(value as ServerProtocolEra)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes server type:
|
||||
* - missing / unknown / non-string → "stdio" (matches Claude Desktop's default)
|
||||
|
|
@ -89,6 +115,7 @@ type StoredInspectorFields = Pick<
|
|||
StoredMCPServer,
|
||||
| "headers"
|
||||
| "metadata"
|
||||
| "protocolEra"
|
||||
| "connectionTimeout"
|
||||
| "requestTimeout"
|
||||
| "taskTtl"
|
||||
|
|
@ -162,6 +189,7 @@ export function storedFieldsToInspectorSettings(
|
|||
stored.maxFetchRequests !== undefined ||
|
||||
stored.oauth !== undefined ||
|
||||
stored.roots !== undefined ||
|
||||
stored.protocolEra !== undefined ||
|
||||
stored.env !== undefined ||
|
||||
stored.cwd !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
|
|
@ -189,6 +217,13 @@ export function storedFieldsToInspectorSettings(
|
|||
// `[]`, which `inspectorSettingsToStoredFields` then omits on write.
|
||||
roots: stored.roots ?? [],
|
||||
};
|
||||
// Absent on disk reads back as the default era; the write side then omits the
|
||||
// default so a byte-stable round-trip never injects `protocolEra` into files
|
||||
// that never set it. An unknown literal from a hand-edited file is dropped
|
||||
// (→ default legacy) rather than passed through to `eraToVersionNegotiation`.
|
||||
if (isProtocolEra(stored.protocolEra)) {
|
||||
settings.protocolEra = stored.protocolEra;
|
||||
}
|
||||
// Truthiness drops empty-string OAuth fields — mirrors the write-side
|
||||
// coercion in `validateSettings` (server.ts) so a round-trip can't
|
||||
// accidentally surface `oauthClientId: ""` to the form, where the
|
||||
|
|
@ -258,6 +293,16 @@ export function inspectorSettingsToStoredFields(
|
|||
out.autoRefreshOnListChanged = true;
|
||||
}
|
||||
|
||||
// Persist only when it differs from the default era. Absent reads back as
|
||||
// DEFAULT_PROTOCOL_ERA, so writing the default would inject the field into
|
||||
// hand-edited files that never had it and break byte-stable round-trips.
|
||||
if (
|
||||
settings.protocolEra !== undefined &&
|
||||
settings.protocolEra !== DEFAULT_PROTOCOL_ERA
|
||||
) {
|
||||
out.protocolEra = settings.protocolEra;
|
||||
}
|
||||
|
||||
// Persist only when it differs from the default. Unlike the timeouts, 0 is a
|
||||
// meaningful value here (unlimited), so the omit-sentinel is the default
|
||||
// itself rather than 0 — writing the default would inject the field into
|
||||
|
|
@ -309,6 +354,7 @@ export function inspectorSettingsToStoredFields(
|
|||
const INSPECTOR_FIELD_KEY_MAP = {
|
||||
headers: true,
|
||||
metadata: true,
|
||||
protocolEra: true,
|
||||
connectionTimeout: true,
|
||||
requestTimeout: true,
|
||||
taskTtl: true,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
ServerNotification,
|
||||
ServerRequest,
|
||||
Tool,
|
||||
VersionNegotiationOptions,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
||||
|
|
@ -102,6 +103,13 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
* shape is preserved on disk and in memory.
|
||||
*/
|
||||
metadata?: { key: string; value: string }[];
|
||||
/**
|
||||
* Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`),
|
||||
* orthogonal to the transport `type`. Inspector-specific (no analog in the
|
||||
* broader mcp.json ecosystem). Omitted on disk when it equals the default
|
||||
* (`"legacy"`). (#1626)
|
||||
*/
|
||||
protocolEra?: ServerProtocolEra;
|
||||
/** Inspector-specific connect-time timeout (ms). */
|
||||
connectionTimeout?: number;
|
||||
/** Inspector-specific request timeout (ms). */
|
||||
|
|
@ -434,6 +442,50 @@ export const DEFAULT_TASK_TTL_MS = 60000;
|
|||
*/
|
||||
export const DEFAULT_MAX_FETCH_REQUESTS = 1000;
|
||||
|
||||
/**
|
||||
* Per-server protocol era (SEP §7.8 backward-compat model), an orthogonal
|
||||
* dimension to the transport `type`. Drives the SDK Client's
|
||||
* `versionNegotiation`:
|
||||
*
|
||||
* - `"legacy"` — the plain 2025-11-25 `initialize` handshake, byte-identical to
|
||||
* a client without negotiation. **This is the default** per the SDK's
|
||||
* guidance that a debugging tool must not auto-probe (a probe stalls on
|
||||
* silent stdio legacy servers and pollutes recorded transcripts).
|
||||
* - `"auto"` — probe `server/discover` at connect and fall back to `initialize`
|
||||
* on any non-modern outcome.
|
||||
* - `"modern"` — pin the modern era at exactly `MODERN_PROTOCOL_VERSION`; no
|
||||
* fallback (a non-modern server fails loudly).
|
||||
*/
|
||||
export type ServerProtocolEra = "legacy" | "auto" | "modern";
|
||||
|
||||
/** The default per-server protocol era when none is configured. */
|
||||
export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy";
|
||||
|
||||
/**
|
||||
* The modern protocol revision `"modern"` era pins to. The successor to
|
||||
* 2025-11-25; the first revision with the per-request-metadata / sessionless
|
||||
* model (SEP §7.1).
|
||||
*/
|
||||
export const MODERN_PROTOCOL_VERSION = "2026-07-28";
|
||||
|
||||
/**
|
||||
* Map a per-server {@link ServerProtocolEra} onto the SDK Client's
|
||||
* `versionNegotiation` option. `"modern"` pins {@link MODERN_PROTOCOL_VERSION};
|
||||
* `"legacy"`/`"auto"` pass their mode straight through.
|
||||
*/
|
||||
export function eraToVersionNegotiation(
|
||||
era: ServerProtocolEra,
|
||||
): VersionNegotiationOptions {
|
||||
switch (era) {
|
||||
case "auto":
|
||||
return { mode: "auto" };
|
||||
case "modern":
|
||||
return { mode: { pin: MODERN_PROTOCOL_VERSION } };
|
||||
case "legacy":
|
||||
return { mode: "legacy" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime settings for a configured server. A subset of
|
||||
* InspectorClientOptions (v1.5) relevant to the settings form:
|
||||
|
|
@ -497,6 +549,14 @@ export interface InspectorServerSettings {
|
|||
* persist (see `inspectorSettingsToStoredFields`).
|
||||
*/
|
||||
roots: Root[];
|
||||
/**
|
||||
* Protocol era to negotiate with this server (orthogonal to the transport
|
||||
* `type`). Drives the SDK Client's `versionNegotiation`. Optional so a bare
|
||||
* settings node reads back without one; absence means {@link
|
||||
* DEFAULT_PROTOCOL_ERA} (`"legacy"`). Persisted on disk as `protocolEra` and
|
||||
* omitted when it equals the default, keeping the file diff minimal.
|
||||
*/
|
||||
protocolEra?: ServerProtocolEra;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -742,6 +802,15 @@ export interface InspectorClientOptions {
|
|||
*/
|
||||
serverSettings?: InspectorServerSettings;
|
||||
|
||||
/**
|
||||
* Protocol version negotiation for the SDK Client (SEP §7.8 era model).
|
||||
* When omitted, the client pins the legacy 2025-11-25 era
|
||||
* (`{ mode: "legacy" }`), byte-identical to a client without negotiation.
|
||||
* Callers derive this from the per-server {@link ServerProtocolEra} via
|
||||
* {@link eraToVersionNegotiation}. (#1626)
|
||||
*/
|
||||
versionNegotiation?: VersionNegotiationOptions;
|
||||
|
||||
/**
|
||||
* OAuth configuration (client credentials, scope, etc.)
|
||||
* Note: OAuth environment components (storage, navigation, redirectUrlProvider)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type {
|
|||
ClientCapabilities,
|
||||
ServerCapabilities,
|
||||
Implementation,
|
||||
ProtocolEra,
|
||||
DiscoverResult,
|
||||
} from "@modelcontextprotocol/client";
|
||||
|
||||
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
|
||||
|
|
@ -22,6 +24,19 @@ export interface UseInspectorClientResult {
|
|||
serverInfo?: Implementation;
|
||||
instructions?: string;
|
||||
protocolVersion?: string;
|
||||
/**
|
||||
* Protocol era negotiated with the server (SEP §7.8): `"legacy"` for the
|
||||
* 2025-11-25 initialize handshake, `"modern"` for the 2026-era sessionless
|
||||
* model. Populated for every era once connected (a plain legacy connect
|
||||
* reports `"legacy"`); undefined only when not connected. (#1626)
|
||||
*/
|
||||
protocolEra?: ProtocolEra;
|
||||
/**
|
||||
* The `server/discover` result on a probed/pinned connect — server identity,
|
||||
* capabilities, and supported versions learned without an initialize
|
||||
* handshake. Undefined on a legacy connect. (#1626)
|
||||
*/
|
||||
discoverResult?: DiscoverResult;
|
||||
/**
|
||||
* Message from the most recent mid-session transport failure (the client's
|
||||
* `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next
|
||||
|
|
@ -66,6 +81,12 @@ export function useInspectorClient(
|
|||
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
|
||||
inspectorClient?.getProtocolVersion(),
|
||||
);
|
||||
const [protocolEra, setProtocolEra] = useState<ProtocolEra | undefined>(
|
||||
inspectorClient?.getProtocolEra(),
|
||||
);
|
||||
const [discoverResult, setDiscoverResult] = useState<
|
||||
DiscoverResult | undefined
|
||||
>(inspectorClient?.getDiscoverResult());
|
||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -75,6 +96,8 @@ export function useInspectorClient(
|
|||
setServerInfo(undefined);
|
||||
setInstructions(undefined);
|
||||
setProtocolVersion(undefined);
|
||||
setProtocolEra(undefined);
|
||||
setDiscoverResult(undefined);
|
||||
setLastError(undefined);
|
||||
return;
|
||||
}
|
||||
|
|
@ -84,6 +107,8 @@ export function useInspectorClient(
|
|||
setServerInfo(inspectorClient.getServerInfo());
|
||||
setInstructions(inspectorClient.getInstructions());
|
||||
setProtocolVersion(inspectorClient.getProtocolVersion());
|
||||
setProtocolEra(inspectorClient.getProtocolEra());
|
||||
setDiscoverResult(inspectorClient.getDiscoverResult());
|
||||
setLastError(undefined);
|
||||
|
||||
const onStatusChange = (event: TypedEvent<"statusChange">) => {
|
||||
|
|
@ -111,6 +136,14 @@ export function useInspectorClient(
|
|||
) => {
|
||||
setProtocolVersion(event.detail);
|
||||
};
|
||||
const onProtocolEraChange = (event: TypedEvent<"protocolEraChange">) => {
|
||||
setProtocolEra(event.detail);
|
||||
};
|
||||
const onDiscoverResultChange = (
|
||||
event: TypedEvent<"discoverResultChange">,
|
||||
) => {
|
||||
setDiscoverResult(event.detail);
|
||||
};
|
||||
|
||||
inspectorClient.addEventListener("statusChange", onStatusChange);
|
||||
inspectorClient.addEventListener("error", onError);
|
||||
|
|
@ -127,6 +160,11 @@ export function useInspectorClient(
|
|||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.addEventListener("protocolEraChange", onProtocolEraChange);
|
||||
inspectorClient.addEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
inspectorClient.removeEventListener("statusChange", onStatusChange);
|
||||
|
|
@ -147,6 +185,14 @@ export function useInspectorClient(
|
|||
"protocolVersionChange",
|
||||
onProtocolVersionChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"protocolEraChange",
|
||||
onProtocolEraChange,
|
||||
);
|
||||
inspectorClient.removeEventListener(
|
||||
"discoverResultChange",
|
||||
onDiscoverResultChange,
|
||||
);
|
||||
};
|
||||
}, [inspectorClient]);
|
||||
|
||||
|
|
@ -173,6 +219,8 @@ export function useInspectorClient(
|
|||
serverInfo,
|
||||
instructions,
|
||||
protocolVersion,
|
||||
protocolEra,
|
||||
discoverResult,
|
||||
lastError,
|
||||
appRendererClient: inspectorClient?.getAppRendererClient() ?? null,
|
||||
connect,
|
||||
|
|
|
|||
Loading…
Reference in a new issue