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

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;
}

View file

@ -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
}

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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,

View file

@ -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)

View file

@ -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,