diff --git a/packages/inspector/src/connection.ts b/packages/inspector/src/connection.ts index 4cd3a7df..9d8ace1c 100644 --- a/packages/inspector/src/connection.ts +++ b/packages/inspector/src/connection.ts @@ -5,6 +5,7 @@ */ import { EventEmitter } from "node:events"; +import { z } from "zod"; import { createTestClient, type TestClient, @@ -1181,6 +1182,65 @@ export class ConnectionManager extends EventEmitter { return count; } + + /** + * Zod schema for validating MCP initialize requests. + * Used by maybeRecordInitialize to safely extract clientInfo. + */ + private static readonly InitializeRequestSchema = z.object({ + method: z.literal("initialize"), + params: z + .object({ + clientInfo: z + .object({ + name: z.string().optional(), + version: z.string().optional(), + }) + .optional(), + }) + .optional(), + }); + + /** + * Check if a JSON-RPC body is an MCP `initialize` request and record an agent-initialize event + * + * This intercepts the MCP initialize handshake to detect when an agent connects. + * The clientInfo.name field identifies the connecting agent (e.g., "claude-code", "cursor"). + * + * @param jsonRpcBody - Parsed JSON-RPC request body (or unknown value to check) + * @returns true if an initialize event was recorded, false otherwise + */ + maybeRecordInitialize(jsonRpcBody: unknown): boolean { + // Use Zod to safely validate and extract the initialize request structure + const parseResult = ConnectionManager.InitializeRequestSchema.safeParse(jsonRpcBody); + + if (!parseResult.success) { + // Not a valid initialize request structure + return false; + } + + const { params } = parseResult.data; + const clientInfo = params?.clientInfo; + + // Build payload from validated data + const payload: Record = {}; + if (clientInfo?.name) { + payload.clientName = clientInfo.name; + } + if (clientInfo?.version) { + payload.clientVersion = clientInfo.version; + } + + this.recordAgentEvent("agent-initialize", payload); + + if (this.debug) { + console.log( + `[inspector] Agent initialize detected: ${clientInfo?.name ?? "unknown"}${clientInfo?.version ? ` v${clientInfo.version}` : ""}` + ); + } + + return true; + } } // ============================================================================= diff --git a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx index 0f1d3de0..78c97ad5 100644 --- a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx +++ b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx @@ -23,7 +23,7 @@ import { TabBar } from "./components/TabBar"; import { GlobalsPanel } from "./components/GlobalsPanel"; import { McpPrimitivesPanel } from "./components/McpPrimitivesPanel"; import { RightPanel } from "./components/RightPanel"; -import { NoWidgetPlaceholder } from "./components/NoWidgetPlaceholder"; +import { NoWidgetPlaceholder, type ConnectionState } from "./components/NoWidgetPlaceholder"; import { OAuthDiscoveryPanel } from "./components/OAuthDiscoveryPanel"; import { styles } from "./styles"; import logoUrl from "../assets/logo.png"; @@ -212,6 +212,32 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R resources.length > 0 ? resources : (cachedState?.primitives?.resources ?? []); const displayPrompts = prompts.length > 0 ? prompts : (cachedState?.primitives?.prompts ?? []); + // Compute connection state for NoWidgetPlaceholder + const connectionState: ConnectionState = useMemo(() => { + // No server connected + if (!activeConnection || connections.length === 0) { + return "no-server"; + } + // Server connected - check for agent-initialize event + if (activeConnection.status === "connected") { + const hasAgentInit = displayAgentEvents.some((e) => e.type === "agent-initialize"); + return hasAgentInit ? "agent-connected" : "server-connected"; + } + // Connecting or error state - treat as no server + return "no-server"; + }, [activeConnection, connections.length, displayAgentEvents]); + + // Extract client name from agent-initialize event + const agentClientName = useMemo(() => { + const initEvent = displayAgentEvents.find((e) => e.type === "agent-initialize"); + return (initEvent?.payload as { clientName?: string } | undefined)?.clientName; + }, [displayAgentEvents]); + + // Handler to open connection form + const handleConnect = useCallback(() => { + setIsConnectionFormOpen(true); + }, []); + // Left panel state (MCP primitives) const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false); @@ -612,7 +638,11 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R ) : ( /* Tamagotchi placeholder when no widget */ - + )} diff --git a/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx b/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx index 2e2f0b53..7697ae4f 100644 --- a/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx +++ b/packages/inspector/src/dashboard/react/components/NoWidgetPlaceholder.tsx @@ -1,18 +1,141 @@ /** * NoWidgetPlaceholder Component * - * Crystalline star image with floating animation. + * Stepped tutorial placeholder with 3-state UI: + * 1. No server connected - prompts to connect server + * 2. Server connected, no agent - prompts to connect agent + * 3. Agent connected - shows ready state with client name */ import React from "react"; +import type { CSSProperties } from "react"; import { styles } from "../styles"; import starUrl from "../../assets/sirius-star.png"; -export function NoWidgetPlaceholder(): React.ReactElement { +/** Connection state for the placeholder UI */ +export type ConnectionState = "no-server" | "server-connected" | "agent-connected"; + +export interface NoWidgetPlaceholderProps { + /** Current connection state */ + connectionState: ConnectionState; + /** Name of connected agent client (only set when agent-connected) */ + clientName?: string; + /** Callback when user clicks Connect Server button */ + onConnect: () => void; +} + +/** + * Module-level button hover handlers. + * Extracted from inline handlers for memoization — these don't depend on component state. + */ +const handleButtonHover = (e: React.MouseEvent) => { + e.currentTarget.style.opacity = "0.9"; + e.currentTarget.style.transform = "scale(1.02)"; +}; + +const handleButtonLeave = (e: React.MouseEvent) => { + e.currentTarget.style.opacity = "1"; + e.currentTarget.style.transform = "scale(1)"; +}; + +/** Local styles for the stepped tutorial layout */ +const localStyles: Record = { + tagline: { + color: "#6b7280", + fontSize: "0.8125rem", + textAlign: "center", + margin: 0, + letterSpacing: "0.01em", + }, + heading: { + color: "#e8e8e8", + fontSize: "1.125rem", + fontWeight: 500, + textAlign: "center", + margin: 0, + letterSpacing: "-0.01em", + }, + subtext: { + color: "#6b7280", + fontSize: "0.875rem", + textAlign: "center", + margin: 0, + lineHeight: 1.5, + }, + clientName: { + color: "#e8e8e8", + fontWeight: 600, + }, + connectButton: { + backgroundColor: "#ffffff", + color: "#000000", + border: "none", + borderRadius: "8px", + padding: "0.625rem 1.25rem", + fontSize: "0.875rem", + fontWeight: 500, + fontFamily: "inherit", + cursor: "pointer", + transition: "opacity 0.15s ease, transform 0.15s ease", + }, + stateContent: { + display: "flex", + flexDirection: "column" as const, + alignItems: "center", + gap: "0.75rem", + marginTop: "0.5rem", + }, +}; + +export function NoWidgetPlaceholder({ + connectionState, + clientName, + onConnect, +}: NoWidgetPlaceholderProps): React.ReactElement { return (
+ {/* Star logo */} Sirius the star -

No active widget yet — ask your agent to test

+ + {/* Tagline - always shown */} +

Debug MCP servers alongside your Agent

+ + {/* State-specific content */} +
+ {connectionState === "no-server" && ( + <> +

Connect the server you want to inspect

+ + + )} + + {connectionState === "server-connected" && ( + <> +

Connect your Agent to this MCP Server

+

+ The inspector will capture all tool calls and responses +

+ + )} + + {connectionState === "agent-connected" && ( + <> +

Ready to Test

+

+ with {clientName ?? "Agent"} +

+ + )} +
); } diff --git a/packages/inspector/src/dashboard/react/components/TabBar.tsx b/packages/inspector/src/dashboard/react/components/TabBar.tsx index 7bd09569..30e49dcf 100644 --- a/packages/inspector/src/dashboard/react/components/TabBar.tsx +++ b/packages/inspector/src/dashboard/react/components/TabBar.tsx @@ -136,7 +136,11 @@ export function TabBar({ onSelect, onClose, onAdd, -}: TabBarProps): React.ReactElement { +}: TabBarProps): React.ReactElement | null { + if (tabs.length === 0) { + return null; + } + return (
diff --git a/packages/inspector/src/dual-server.ts b/packages/inspector/src/dual-server.ts index 03f469a4..a67b2cbf 100644 --- a/packages/inspector/src/dual-server.ts +++ b/packages/inspector/src/dual-server.ts @@ -716,6 +716,20 @@ export function createDualInspectorServer( } const body = Buffer.concat(chunks); + // Intercept MCP initialize requests on both endpoints for agent detection + if (body.length > 0 && (url.startsWith("/agent/mcp") || url.startsWith("/apps/mcp"))) { + try { + const parsed = JSON.parse(body.toString("utf-8")) as unknown; + const cm = getActiveConnectionManager(); + if (cm) { + cm.maybeRecordInitialize(parsed); + } + } catch { + // Parse failures handled silently — non-JSON bodies on MCP endpoints are expected + // (e.g., SSE connections, partial uploads). Debug logging omitted to avoid noise. + } + } + const webRequest = new Request(requestUrl, { method: req.method ?? "GET", headers: Object.entries(req.headers) diff --git a/packages/inspector/src/standalone-server.ts b/packages/inspector/src/standalone-server.ts index bb8e96be..f2c3be7c 100644 --- a/packages/inspector/src/standalone-server.ts +++ b/packages/inspector/src/standalone-server.ts @@ -808,7 +808,7 @@ export function createStandaloneInspectorServer( } const body = Buffer.concat(chunks); - // Track inspector tool calls for the Agent panel + // Track inspector tool calls and agent initialization for the Agent panel let inspectorToolCall: { name: string; arguments: unknown; startTime: number } | null = null; if (body.length > 0) { try { @@ -816,6 +816,14 @@ export function createStandaloneInspectorServer( method?: string; params?: { name?: string; arguments?: unknown }; }; + + const connectionManager = getActiveConnectionManager(); + + // Check for MCP initialize request (agent detection) + if (connectionManager) { + connectionManager.maybeRecordInitialize(parsed); + } + // Check if this is a tools/call request (MCP JSON-RPC) if (parsed.method === "tools/call" && parsed.params?.name) { inspectorToolCall = { @@ -824,7 +832,6 @@ export function createStandaloneInspectorServer( startTime: Date.now(), }; // Record inspector tool call event - const connectionManager = getActiveConnectionManager(); if (connectionManager) { const eventPayload: Record = { name: inspectorToolCall.name, @@ -840,7 +847,8 @@ export function createStandaloneInspectorServer( } } } catch { - // Not valid JSON or not a tool call, ignore + // Parse failures handled silently — non-JSON bodies on MCP endpoints are expected + // (e.g., SSE connections, partial uploads). Debug logging omitted to avoid noise. } } diff --git a/packages/inspector/src/types/inspector-event-types.ts b/packages/inspector/src/types/inspector-event-types.ts index be5182ea..c29f2208 100644 --- a/packages/inspector/src/types/inspector-event-types.ts +++ b/packages/inspector/src/types/inspector-event-types.ts @@ -61,7 +61,8 @@ export type InspectorEventType = | "dialog" // Agent events (session-agnostic tool calls from the inspector) | "agent-tool-call" - | "agent-tool-result"; + | "agent-tool-result" + | "agent-initialize"; /** * Inspector event record @@ -158,6 +159,7 @@ export function getEventCategory(type: InspectorEventType): EventCategory { case "agent-tool-call": case "agent-tool-result": + case "agent-initialize": return "agent"; } } @@ -270,5 +272,10 @@ export function getEventSummary(event: InspectorEvent | AgnosticInspectorEvent): const toolName = getStr(payload, "name") ?? getStr(payload, "toolName") ?? "unknown"; return isError ? `Agent Error: ${toolName}` : `Agent Result: ${toolName}`; } + + case "agent-initialize": { + const clientName = getStr(payload, "clientName") ?? getStr(payload, "name"); + return clientName ? `Agent Connected: ${clientName}` : "Agent Connected"; + } } } diff --git a/packages/inspector/tests/connection.test.ts b/packages/inspector/tests/connection.test.ts index a6d66fa7..f21707ab 100644 --- a/packages/inspector/tests/connection.test.ts +++ b/packages/inspector/tests/connection.test.ts @@ -132,4 +132,83 @@ describe("ConnectionManager", () => { expect(count).toBe(0); }); }); + + describe("maybeRecordInitialize", () => { + it("should record event for valid initialize request with clientInfo", () => { + const jsonRpcBody = { + method: "initialize", + params: { + clientInfo: { + name: "claude-code", + version: "1.0.0", + }, + }, + }; + + const result = manager.maybeRecordInitialize(jsonRpcBody); + + expect(result).toBe(true); + const events = manager.getAgentEvents(); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("agent-initialize"); + expect(events[0].payload).toEqual({ + clientName: "claude-code", + clientVersion: "1.0.0", + }); + }); + + it("should record event with undefined name when clientInfo is missing", () => { + const jsonRpcBody = { + method: "initialize", + params: {}, + }; + + const result = manager.maybeRecordInitialize(jsonRpcBody); + + expect(result).toBe(true); + const events = manager.getAgentEvents(); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("agent-initialize"); + expect(events[0].payload).toEqual({}); + }); + + it("should return false for invalid JSON-RPC structure (no method)", () => { + const jsonRpcBody = { + params: { + clientInfo: { name: "test" }, + }, + }; + + const result = manager.maybeRecordInitialize(jsonRpcBody); + + expect(result).toBe(false); + expect(manager.getAgentEvents()).toHaveLength(0); + }); + + it("should return false for non-initialize method", () => { + const jsonRpcBody = { + method: "tools/list", + params: {}, + }; + + const result = manager.maybeRecordInitialize(jsonRpcBody); + + expect(result).toBe(false); + expect(manager.getAgentEvents()).toHaveLength(0); + }); + + it("should record event with undefined clientInfo when params is missing", () => { + const jsonRpcBody = { + method: "initialize", + }; + + const result = manager.maybeRecordInitialize(jsonRpcBody); + + expect(result).toBe(true); + const events = manager.getAgentEvents(); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("agent-initialize"); + expect(events[0].payload).toEqual({}); + }); + }); });