From 9b558afee1c210865029da9426ff49b6b8f81f38 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Mon, 8 Jun 2026 07:40:02 -0400 Subject: [PATCH] feat(web): display negotiated MCP protocol version on ServerCard (#1324) (#1442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): show negotiated MCP protocol version on ServerCard (#1324) Add optional protocolVersion to ConnectionState and render it as a dimmed "MCP " 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) * 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 ". 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/src/App.tsx | 24 ++++++-- .../groups/ServerCard/ServerCard.stories.tsx | 5 +- .../groups/ServerCard/ServerCard.test.tsx | 29 +++++++++- .../groups/ServerCard/ServerCard.tsx | 11 ++++ .../InspectorView/InspectorView.test.tsx | 55 +++++++++++++++++++ .../views/InspectorView/InspectorView.tsx | 18 +++++- .../core/mcp/messageTrackingTransport.test.ts | 31 +++++++++++ .../core/react/useInspectorClient.test.tsx | 13 +++++ .../integration/mcp/inspectorClient.test.ts | 22 ++++++++ core/mcp/__tests__/fakeInspectorClient.ts | 12 ++++ core/mcp/inspectorClient.ts | 19 +++++++ core/mcp/inspectorClientEventTarget.ts | 1 + core/mcp/inspectorClientProtocol.ts | 1 + core/mcp/messageTrackingTransport.ts | 17 +++++- core/mcp/types.ts | 7 +++ core/react/useInspectorClient.ts | 20 +++++++ 16 files changed, 273 insertions(+), 12 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 093d6222..878799fb 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -600,6 +600,7 @@ function App() { clientCapabilities, serverInfo, instructions, + protocolVersion, } = useInspectorClient(inspectorClient); const { tools, refresh: refreshTools } = useManagedTools( inspectorClient, @@ -880,19 +881,30 @@ function App() { }; }, [inspectorClient]); - // Build the InitializeResult the connected ViewHeader expects from the - // hook's split fields. `protocolVersion` is hard-coded for now — the - // useInspectorClient hook doesn't expose it. TODO(#1324): consume the - // negotiated value once the hook surfaces it. + // Build the InitializeResult the connected ViewHeader / Connection Info + // modal expect from the hook's split fields. `protocolVersion` is the value + // the InspectorClient negotiated during initialize (#1324); it's dispatched + // alongside serverInfo, so in practice it's present whenever we're connected. + // We deliberately gate only on serverInfo (not protocolVersion): this object + // also drives the connected header and Connection Info modal, so a + // missing/edge-case version must not hide those. It flows through as the + // optional field it is everywhere downstream (the ServerCard label and the + // modal value both tolerate an empty string), so "" reads as "unknown". const initializeResult = useMemo(() => { if (connectionStatus !== "connected" || !serverInfo) return undefined; return { - protocolVersion: "2025-06-18", + protocolVersion: protocolVersion ?? "", capabilities: capabilities ?? {}, serverInfo, ...(instructions ? { instructions } : {}), }; - }, [connectionStatus, capabilities, serverInfo, instructions]); + }, [ + connectionStatus, + capabilities, + serverInfo, + instructions, + protocolVersion, + ]); // The Server Info modal needs the active server's transport and (optional) // OAuth details — both are co-located here so the modal opens against the diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx index 6db7f8a9..cd516b95 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx @@ -15,7 +15,10 @@ const httpConfig: MCPServerConfig = { url: "https://api.example.com/mcp", }; -const connected: ConnectionState = { status: "connected" }; +const connected: ConnectionState = { + status: "connected", + protocolVersion: "2025-06-18", +}; const disconnected: ConnectionState = { status: "disconnected" }; const connecting: ConnectionState = { status: "connecting" }; const failed: ConnectionState = { diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx index 4a2cae58..02cd05fd 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx @@ -21,7 +21,10 @@ const sseConfig: MCPServerConfig = { url: "https://api.example.com/sse", }; -const connected: ConnectionState = { status: "connected" }; +const connected: ConnectionState = { + status: "connected", + protocolVersion: "2025-06-18", +}; const disconnected: ConnectionState = { status: "disconnected" }; const connecting: ConnectionState = { status: "connecting" }; const errored: ConnectionState = { @@ -149,6 +152,30 @@ describe("ServerCard", () => { expect(container.querySelector('[aria-disabled="true"]')).toBeNull(); }); + it("renders the negotiated protocol version when connected", () => { + renderWithMantine(); + expect(screen.getByText("MCP 2025-06-18")).toBeInTheDocument(); + }); + + it("omits the protocol version when not connected", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("MCP 2025-06-18")).not.toBeInTheDocument(); + }); + + it("omits the protocol version when connected but none was negotiated", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText(/^MCP \d{4}-\d{2}-\d{2}$/), + ).not.toBeInTheDocument(); + }); + it("omits the version badge when info is missing", () => { renderWithMantine(); expect(screen.queryByText("1.2.0")).not.toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index 359040e3..98d91db3 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -49,6 +49,12 @@ const ModeText = Text.withProps({ c: "dimmed", }); +const ProtocolText = Text.withProps({ + size: "sm", + c: "dimmed", + ml: "auto", +}); + const SubtleButton = Button.withProps({ variant: "subtle", size: "xs", @@ -102,6 +108,8 @@ export function ServerCard({ const transport = getTransport(config); const commandOrUrl = getCommandOrUrl(config); const version = info?.version; + const protocolVersion = + connection.status === "connected" ? connection.protocolVersion : undefined; return ( {version}} {TRANSPORT_DESCRIPTION[transport]} + {protocolVersion && ( + MCP {protocolVersion} + )} { expect(screen.getByRole("switch")).toBeChecked(); }); + it("surfaces the negotiated protocol version on the active connected card", () => { + renderWithMantine( + , + ); + // initializeResult.protocolVersion is spliced onto the active server's + // connection; ServerCard renders it as "MCP ". + expect(screen.getByText("MCP 2025-06-18")).toBeInTheDocument(); + }); + + it("does not show a protocol version on the card while disconnected", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText(/^MCP \d{4}-\d{2}-\d{2}$/), + ).not.toBeInTheDocument(); + }); + + it("keeps the connected surface and hides the label when the version is unknown", () => { + // App emits initializeResult with protocolVersion "" when the negotiated + // version is somehow absent — the connected header/modal must still render + // (gated on serverInfo, not the version), and the card label stays hidden. + renderWithMantine( + , + ); + // Connected: the card toggle is on (connected surface is alive). + expect(screen.getByRole("switch")).toBeChecked(); + // ...but no "MCP " label, since the version is empty. (The + // date-shaped matcher avoids matching the "MCP Inspector" header title.) + expect( + screen.queryByText(/^MCP \d{4}-\d{2}-\d{2}$/), + ).not.toBeInTheDocument(); + }); + it("snaps activeTab back to Servers when connection drops", async () => { const { rerender } = renderWithMantine( void; onclose?: () => void; onerror?: (error: Error) => void; + // Optional on the SDK Transport interface; stdio omits it, HTTP defines it. + setProtocolVersion?: (version: string) => void; async start(): Promise {} async send(message: JSONRPCMessage): Promise { this.sent.push(message); @@ -120,3 +122,32 @@ describe("MessageTrackingTransport.onmessage", () => { expect(handler).toHaveBeenCalledTimes(3); }); }); + +describe("MessageTrackingTransport.setProtocolVersion", () => { + it("captures the negotiated version and exposes it via protocolVersion", () => { + const { tracked } = makeTracked(); + expect(tracked.protocolVersion).toBeUndefined(); + tracked.setProtocolVersion("2025-06-18"); + expect(tracked.protocolVersion).toBe("2025-06-18"); + }); + + it("forwards to the base transport's setProtocolVersion when present", () => { + const base = new FakeTransport(); + const baseSet = vi.fn(); + // stdio-style transports omit setProtocolVersion; HTTP transports define + // it to stamp the version into later request headers — forward to those. + base.setProtocolVersion = baseSet; + const tracked = new MessageTrackingTransport(base, {}); + tracked.setProtocolVersion("2025-06-18"); + expect(baseSet).toHaveBeenCalledWith("2025-06-18"); + expect(tracked.protocolVersion).toBe("2025-06-18"); + }); + + it("captures even when the base transport has no setProtocolVersion", () => { + // FakeTransport (like stdio) has no setProtocolVersion — must not throw. + const { tracked, base } = makeTracked(); + expect(base.setProtocolVersion).toBeUndefined(); + expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow(); + expect(tracked.protocolVersion).toBe("2025-06-18"); + }); +}); diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index c47b23e5..7fddd5f2 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -19,12 +19,14 @@ describe("useInspectorClient", () => { capabilities: CAPABILITIES, serverInfo: SERVER_INFO, instructions: "hello", + protocolVersion: "2025-06-18", }); const { result } = renderHook(() => useInspectorClient(client)); expect(result.current.status).toBe("connected"); expect(result.current.capabilities).toEqual(CAPABILITIES); expect(result.current.serverInfo).toEqual(SERVER_INFO); expect(result.current.instructions).toBe("hello"); + expect(result.current.protocolVersion).toBe("2025-06-18"); expect(result.current.appRendererClient).toBeNull(); }); @@ -34,6 +36,7 @@ describe("useInspectorClient", () => { expect(result.current.capabilities).toBeUndefined(); expect(result.current.serverInfo).toBeUndefined(); expect(result.current.instructions).toBeUndefined(); + expect(result.current.protocolVersion).toBeUndefined(); expect(result.current.appRendererClient).toBeNull(); }); @@ -64,6 +67,16 @@ describe("useInspectorClient", () => { expect(result.current.instructions).toBe("after"); }); + it("subscribes to protocolVersionChange and updates", () => { + const client = new FakeInspectorClient(); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.protocolVersion).toBeUndefined(); + act(() => { + client.setProtocolVersion("2025-06-18"); + }); + expect(result.current.protocolVersion).toBe("2025-06-18"); + }); + it("connect() and disconnect() proxy to the client and update status", async () => { const client = new FakeInspectorClient(); const { result } = renderHook(() => useInspectorClient(client)); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 5b5af9d5..c4610610 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -673,6 +673,28 @@ describe("InspectorClient", () => { expect(client.getServerInfo()).toBeDefined(); }); + it("exposes the negotiated protocol version after connect (stdio)", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + 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. + expect(client.getProtocolVersion()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + await client.disconnect(); + expect(client.getProtocolVersion()).toBeUndefined(); + }); + it("should not auto-fetch server contents when disabled", async () => { client = new InspectorClient( { diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 230eda4b..e2f00774 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -43,6 +43,7 @@ export interface FakeInspectorClientOptions { clientCapabilities?: ClientCapabilities; serverInfo?: Implementation; instructions?: string; + protocolVersion?: string; } export class FakeInspectorClient @@ -54,6 +55,7 @@ export class FakeInspectorClient private clientCapabilities: ClientCapabilities; private serverInfo: Implementation | undefined; private instructions: string | undefined; + private protocolVersion: string | undefined; private appRendererClient: AppRendererClient | null = null; private sessionId: string | undefined; private pendingSamples: SamplingCreateMessage[] = []; @@ -147,6 +149,7 @@ export class FakeInspectorClient this.clientCapabilities = options.clientCapabilities ?? {}; this.serverInfo = options.serverInfo; this.instructions = options.instructions; + this.protocolVersion = options.protocolVersion; } getStatus(): ConnectionStatus { @@ -169,6 +172,10 @@ export class FakeInspectorClient return this.instructions; } + getProtocolVersion(): string | undefined { + return this.protocolVersion; + } + getAppRendererClient(): AppRendererClient | null { return this.appRendererClient; } @@ -235,6 +242,11 @@ export class FakeInspectorClient this.dispatchTypedEvent("instructionsChange", instructions); } + setProtocolVersion(protocolVersion: string | undefined): void { + this.protocolVersion = protocolVersion; + this.dispatchTypedEvent("protocolVersionChange", protocolVersion); + } + setAppRendererClient(client: AppRendererClient | null): void { this.appRendererClient = client; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 81ffe7bb..d90bd2a1 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -170,6 +170,7 @@ export class InspectorClient extends InspectorClientEventTarget { private capabilities?: ServerCapabilities; private serverInfo?: Implementation; private instructions?: string; + private protocolVersion?: string; // 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 @@ -1050,11 +1051,13 @@ export class InspectorClient extends InspectorClientEventTarget { this.capabilities = undefined; this.serverInfo = undefined; this.instructions = undefined; + this.protocolVersion = undefined; this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations); this.dispatchTypedEvent("capabilitiesChange", this.capabilities); this.dispatchTypedEvent("serverInfoChange", this.serverInfo); this.dispatchTypedEvent("instructionsChange", this.instructions); + this.dispatchTypedEvent("protocolVersionChange", this.protocolVersion); } /** @@ -1295,6 +1298,14 @@ export class InspectorClient extends InspectorClientEventTarget { return this.instructions; } + /** + * Get the MCP protocol version negotiated with the server during the + * initialize handshake (e.g. "2025-06-18"). Undefined when not connected. + */ + getProtocolVersion(): string | undefined { + return this.protocolVersion; + } + /** * Set the logging level for the MCP server * @param level Logging level to set @@ -2193,6 +2204,14 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.instructions !== undefined) { 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); + } } catch { // Ignore errors in fetching server info } diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index c09754d8..fd5d0147 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -55,6 +55,7 @@ export interface InspectorClientEventMap { capabilitiesChange: ServerCapabilities | undefined; serverInfoChange: Implementation | undefined; instructionsChange: string | undefined; + protocolVersionChange: string | undefined; message: MessageEntry; stderrLog: StderrLogEntry; fetchRequest: FetchRequestEntry; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 2b0ddaab..64102ad0 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -54,6 +54,7 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { getClientCapabilities(): ClientCapabilities; getServerInfo(): Implementation | undefined; getInstructions(): string | undefined; + getProtocolVersion(): string | undefined; getAppRendererClient(): AppRendererClient | null; // Connection control diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index c0e98caa..80fc40cf 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -30,6 +30,7 @@ export interface MessageTrackingCallbacks { export class MessageTrackingTransport implements Transport { private baseTransport: Transport; private callbacks: MessageTrackingCallbacks; + private negotiatedProtocolVersion?: string; constructor( baseTransport: Transport, @@ -146,7 +147,19 @@ export class MessageTrackingTransport implements Transport { return this.baseTransport.sessionId; } - get setProtocolVersion(): ((version: string) => void) | undefined { - return this.baseTransport.setProtocolVersion; + // Implemented as a concrete method (rather than delegating the base + // transport's optional `setProtocolVersion`) so the SDK Client always + // invokes it after the initialize handshake — including for stdio, whose + // base transport has no `setProtocolVersion`. We capture the negotiated + // version for the UI here, then forward to the base transport when it + // cares (HTTP transports stamp it into subsequent request headers). + setProtocolVersion(version: string): void { + this.negotiatedProtocolVersion = version; + this.baseTransport.setProtocolVersion?.(version); + } + + /** MCP protocol version negotiated during initialize, once connected. */ + get protocolVersion(): string | undefined { + return this.negotiatedProtocolVersion; } } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index c01080d9..9c02571a 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -143,6 +143,13 @@ export interface ConnectionState { status: ConnectionStatus; retryCount?: number; error?: { message: string; details?: string }; + /** + * MCP protocol version negotiated with the server during initialize + * (e.g. "2025-06-18"). Only present once connected; surfaced in the + * ServerCard transport row. Populated when #1324 plumbs the value + * through `useInspectorClient`. + */ + protocolVersion?: string; } export interface ServerEntry { diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index e5f211b0..de873f7c 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -21,6 +21,7 @@ export interface UseInspectorClientResult { clientCapabilities: ClientCapabilities; serverInfo?: Implementation; instructions?: string; + protocolVersion?: string; appRendererClient: AppRendererClient | null; connect: () => Promise; disconnect: () => Promise; @@ -54,6 +55,9 @@ export function useInspectorClient( const [instructions, setInstructions] = useState( inspectorClient?.getInstructions(), ); + const [protocolVersion, setProtocolVersion] = useState( + inspectorClient?.getProtocolVersion(), + ); useEffect(() => { if (!inspectorClient) { @@ -61,6 +65,7 @@ export function useInspectorClient( setCapabilities(undefined); setServerInfo(undefined); setInstructions(undefined); + setProtocolVersion(undefined); return; } @@ -68,6 +73,7 @@ export function useInspectorClient( setCapabilities(inspectorClient.getCapabilities()); setServerInfo(inspectorClient.getServerInfo()); setInstructions(inspectorClient.getInstructions()); + setProtocolVersion(inspectorClient.getProtocolVersion()); const onStatusChange = (event: TypedEvent<"statusChange">) => { setStatus(event.detail); @@ -81,6 +87,11 @@ export function useInspectorClient( const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => { setInstructions(event.detail); }; + const onProtocolVersionChange = ( + event: TypedEvent<"protocolVersionChange">, + ) => { + setProtocolVersion(event.detail); + }; inspectorClient.addEventListener("statusChange", onStatusChange); inspectorClient.addEventListener( @@ -92,6 +103,10 @@ export function useInspectorClient( "instructionsChange", onInstructionsChange, ); + inspectorClient.addEventListener( + "protocolVersionChange", + onProtocolVersionChange, + ); return () => { inspectorClient.removeEventListener("statusChange", onStatusChange); @@ -107,6 +122,10 @@ export function useInspectorClient( "instructionsChange", onInstructionsChange, ); + inspectorClient.removeEventListener( + "protocolVersionChange", + onProtocolVersionChange, + ); }; }, [inspectorClient]); @@ -132,6 +151,7 @@ export function useInspectorClient( inspectorClient?.getClientCapabilities() ?? EMPTY_CLIENT_CAPABILITIES, serverInfo, instructions, + protocolVersion, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, disconnect,