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,