inspector/core/react/useInspectorClient.ts
Cliff Hall 9b558afee1
feat(web): display negotiated MCP protocol version on ServerCard (#1324) (#1442)
* feat(web): show negotiated MCP protocol version on ServerCard (#1324)

Add optional protocolVersion to ConnectionState and render it as a
dimmed "MCP <version>" label at the end of the transport-badge/
description row on ServerCard, shown only when connected. The value
is plumbed through useInspectorClient as part of #1324.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core,web): surface negotiated protocolVersion end-to-end (#1324)

Plumb the MCP protocol version negotiated during initialize from the
InspectorClient through to the ServerCard so the active connected card
displays "MCP <version>".

The SDK Client doesn't expose protocolVersion; it stamps it onto the
transport via setProtocolVersion. MessageTrackingTransport now captures
it (and forwards to the base transport when present), so it works for
stdio too — whose base transport has no setProtocolVersion.

- core: MessageTrackingTransport captures + exposes protocolVersion;
  InspectorClient stores it, exposes getProtocolVersion(), dispatches a
  protocolVersionChange event, and clears it on disconnect.
- core: useInspectorClient subscribes and returns protocolVersion.
- web: App reads it from the hook (drops the hard-coded "2025-06-18")
  and InspectorView splices it onto the active server's connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): decouple initializeResult from protocolVersion (#1324 review)

Gate the initializeResult memo on serverInfo only, not protocolVersion.
initializeResult also drives the connected ViewHeader and Connection Info
modal, so requiring the version risked hiding the whole connected surface
in the edge case where it's absent. protocolVersion now flows through as
the optional field it already is everywhere downstream ('' reads as
unknown; the ServerCard label and modal value both tolerate it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): drop redundant connected-check in InspectorView splice (#1324 review)

initializeResult is derived from connectionStatus in App's memo, so a
truthy initializeResult.protocolVersion already implies a live
connection — the extra connectionStatus === 'connected' guard in the
splice was redundant. ServerCard keeps its own status guard (dumb-
component robustness). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:40:02 -04:00

159 lines
5.6 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import type { InspectorClientProtocol } from "../mcp/inspectorClientProtocol.js";
import type { AppRendererClient } from "../mcp/inspectorClientProtocol.js";
import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js";
import type { ConnectionStatus } from "../mcp/types.js";
import type {
ClientCapabilities,
ServerCapabilities,
Implementation,
} from "@modelcontextprotocol/sdk/types.js";
// Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES`
// fallback below doesn't return a fresh literal on every render —
// downstream `useMemo`/`useEffect` deps that key on `clientCapabilities`
// would otherwise invalidate every tick when no client is attached.
const EMPTY_CLIENT_CAPABILITIES: ClientCapabilities = Object.freeze({});
export interface UseInspectorClientResult {
status: ConnectionStatus;
capabilities?: ServerCapabilities;
clientCapabilities: ClientCapabilities;
serverInfo?: Implementation;
instructions?: string;
protocolVersion?: string;
appRendererClient: AppRendererClient | null;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
}
/**
* React hook that subscribes to InspectorClient events and provides reactive
* connection state. Log lists (message / stderr / fetch) live in dedicated
* state managers consumed via useMessageLog / useStderrLog / useFetchRequestLog.
*
* Note: `appRendererClient` is read lazily from the client on every render
* and is NOT subscribed. It changes once at connect time and is not expected
* to change again during a session, so callers will see the current value
* on any rerender triggered by status / capabilities / serverInfo / instructions.
* If a future use case requires autonomous updates when the renderer attaches,
* add an `appRendererClientChange` event to `InspectorClientEventMap` and
* subscribe here.
*/
export function useInspectorClient(
inspectorClient: InspectorClientProtocol | null,
): UseInspectorClientResult {
const [status, setStatus] = useState<ConnectionStatus>(
inspectorClient?.getStatus() ?? "disconnected",
);
const [capabilities, setCapabilities] = useState<
ServerCapabilities | undefined
>(inspectorClient?.getCapabilities());
const [serverInfo, setServerInfo] = useState<Implementation | undefined>(
inspectorClient?.getServerInfo(),
);
const [instructions, setInstructions] = useState<string | undefined>(
inspectorClient?.getInstructions(),
);
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
inspectorClient?.getProtocolVersion(),
);
useEffect(() => {
if (!inspectorClient) {
setStatus("disconnected");
setCapabilities(undefined);
setServerInfo(undefined);
setInstructions(undefined);
setProtocolVersion(undefined);
return;
}
setStatus(inspectorClient.getStatus());
setCapabilities(inspectorClient.getCapabilities());
setServerInfo(inspectorClient.getServerInfo());
setInstructions(inspectorClient.getInstructions());
setProtocolVersion(inspectorClient.getProtocolVersion());
const onStatusChange = (event: TypedEvent<"statusChange">) => {
setStatus(event.detail);
};
const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => {
setCapabilities(event.detail);
};
const onServerInfoChange = (event: TypedEvent<"serverInfoChange">) => {
setServerInfo(event.detail);
};
const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => {
setInstructions(event.detail);
};
const onProtocolVersionChange = (
event: TypedEvent<"protocolVersionChange">,
) => {
setProtocolVersion(event.detail);
};
inspectorClient.addEventListener("statusChange", onStatusChange);
inspectorClient.addEventListener(
"capabilitiesChange",
onCapabilitiesChange,
);
inspectorClient.addEventListener("serverInfoChange", onServerInfoChange);
inspectorClient.addEventListener(
"instructionsChange",
onInstructionsChange,
);
inspectorClient.addEventListener(
"protocolVersionChange",
onProtocolVersionChange,
);
return () => {
inspectorClient.removeEventListener("statusChange", onStatusChange);
inspectorClient.removeEventListener(
"capabilitiesChange",
onCapabilitiesChange,
);
inspectorClient.removeEventListener(
"serverInfoChange",
onServerInfoChange,
);
inspectorClient.removeEventListener(
"instructionsChange",
onInstructionsChange,
);
inspectorClient.removeEventListener(
"protocolVersionChange",
onProtocolVersionChange,
);
};
}, [inspectorClient]);
const connect = useCallback(async () => {
if (!inspectorClient) return;
await inspectorClient.connect();
}, [inspectorClient]);
const disconnect = useCallback(async () => {
if (!inspectorClient) return;
await inspectorClient.disconnect();
}, [inspectorClient]);
return {
status,
capabilities,
// Read lazily on every render rather than subscribed: client capabilities
// are built once in InspectorClient's constructor (from `sample`, `elicit`,
// `roots`, `receiverTasks`) and never mutate during a session, so there's
// no event to subscribe to. The module-scope frozen empty object is the
// stable fallback when no client is attached.
clientCapabilities:
inspectorClient?.getClientCapabilities() ?? EMPTY_CLIENT_CAPABILITIES,
serverInfo,
instructions,
protocolVersion,
appRendererClient: inspectorClient?.getAppRendererClient() ?? null,
connect,
disconnect,
};
}