Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/inspector/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { EventEmitter } from "node:events";
import { z } from "zod";
import {
createTestClient,
type TestClient,
Expand Down Expand Up @@ -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<string, unknown> = {};
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;
}
}

// =============================================================================
Expand Down
34 changes: 32 additions & 2 deletions packages/inspector/src/dashboard/react/InspectorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -612,7 +638,11 @@ export function InspectorDashboard({ baseUrl = "" }: InspectorDashboardProps): R
</div>
) : (
/* Tamagotchi placeholder when no widget */
<NoWidgetPlaceholder />
<NoWidgetPlaceholder
connectionState={connectionState}
clientName={agentClientName}
onConnect={handleConnect}
/>
)}
</main>

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => {
e.currentTarget.style.opacity = "0.9";
e.currentTarget.style.transform = "scale(1.02)";
};

const handleButtonLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.opacity = "1";
e.currentTarget.style.transform = "scale(1)";
};

/** Local styles for the stepped tutorial layout */
const localStyles: Record<string, CSSProperties> = {
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 (
<div style={styles.noWidgetWrapper}>
{/* Star logo */}
<img src={starUrl} alt="Sirius the star" width={90} height={90} style={styles.noWidgetStar} />
<p style={styles.noWidgetMessage}>No active widget yet — ask your agent to test</p>

{/* Tagline - always shown */}
<p style={localStyles.tagline}>Debug MCP servers alongside your Agent</p>

{/* State-specific content */}
<div style={localStyles.stateContent}>
{connectionState === "no-server" && (
<>
<h2 style={localStyles.heading}>Connect the server you want to inspect</h2>
<button
type="button"
aria-label="Connect to MCP server"
style={localStyles.connectButton}
onClick={onConnect}
onMouseEnter={handleButtonHover}
onMouseLeave={handleButtonLeave}
>
Connect Server
</button>
</>
)}

{connectionState === "server-connected" && (
<>
<h2 style={localStyles.heading}>Connect your Agent to this MCP Server</h2>
<p style={localStyles.subtext}>
The inspector will capture all tool calls and responses
</p>
</>
)}

{connectionState === "agent-connected" && (
<>
<h2 style={localStyles.heading}>Ready to Test</h2>
<p style={localStyles.subtext}>
with <span style={localStyles.clientName}>{clientName ?? "Agent"}</span>
</p>
</>
)}
</div>
</div>
);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/inspector/src/dashboard/react/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ export function TabBar({
onSelect,
onClose,
onAdd,
}: TabBarProps): React.ReactElement {
}: TabBarProps): React.ReactElement | null {
if (tabs.length === 0) {
return null;
}

return (
<div style={tabBarStyles.container}>
<div style={tabBarStyles.tabs}>
Expand Down
14 changes: 14 additions & 0 deletions packages/inspector/src/dual-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions packages/inspector/src/standalone-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,14 +808,22 @@ 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 {
const parsed = JSON.parse(body.toString("utf-8")) as {
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 = {
Expand All @@ -824,7 +832,6 @@ export function createStandaloneInspectorServer(
startTime: Date.now(),
};
// Record inspector tool call event
const connectionManager = getActiveConnectionManager();
if (connectionManager) {
const eventPayload: Record<string, unknown> = {
name: inspectorToolCall.name,
Expand All @@ -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.
}
}

Expand Down
9 changes: 8 additions & 1 deletion packages/inspector/src/types/inspector-event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,6 +159,7 @@ export function getEventCategory(type: InspectorEventType): EventCategory {

case "agent-tool-call":
case "agent-tool-result":
case "agent-initialize":
return "agent";
}
}
Expand Down Expand Up @@ -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";
}
}
}
Loading