From 6de21068281ccc1be8ca43eae51b241b8b12cc7d Mon Sep 17 00:00:00 2001 From: Sirius Date: Fri, 30 Jan 2026 18:55:40 +0100 Subject: [PATCH 1/8] feat(inspector): stdio MCP server transport support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stdio transport alongside existing HTTP for connecting to MCP servers that communicate over stdin/stdout. ## Architecture - ConnectionParams discriminated union: { transport: 'http', url } | { transport: 'stdio', command, args?, env?, cwd? } - ONE branch point at createTestClient() — all other code is transport-agnostic - Auto-restart for stdio with exponential backoff (1s, 2s, 4s, max 3 retries) ## Changes ### Types & Transport (Phase 1) - Add ConnectionParams type to @mcp-apps-kit/testing - createTestClient() accepts ConnectionParams, branches to StdioClientTransport or StreamableHTTPClientTransport - Add onTransportClose callback to TestClientOptions - Add connectionParams field to ConnectionState ### Connection Chain (Phase 2) - ConnectionManager.connect() accepts ConnectionParams with input validation - Auto-restart logic: onTransportClose → exponential backoff reconnect (stdio only) - ConnectionRegistry.createConnection() accepts ConnectionParams - All callers updated (28+ test files, 4 source files) ### Tool API (Phase 3) - connect_to_server tool supports both transports via Zod union schema - Backward compatible: plain { url } still works (defaults to HTTP) ### Dashboard API (Phase 4) - POST /dashboard/connections accepts ConnectionParams body - Backward compat: { url } without transport field defaults to HTTP - Response includes transport type ### Dashboard UI (Phase 5) - Transport dropdown (HTTP/stdio) in ConnectionBar - stdio mode: command + args inputs replace URL input - Advanced Settings toggle: env vars + cwd (stdio only) - Server history stores transport type, shows stdio: badge - Selecting stdio history entry populates command/args fields --- .../minimal/tests/advanced-features.test.ts | 23 +- examples/minimal/tests/greet-v1.test.ts | 9 +- examples/minimal/tests/greet-v2.test.ts | 9 +- examples/minimal/tests/integration.test.ts | 11 +- .../tests/integration/versioning.test.ts | 20 +- .../tests/integration/server.test.ts | 11 +- packages/create-app/src/index.ts | 4 +- packages/inspector/src/connection-registry.ts | 7 +- packages/inspector/src/connection.ts | 104 +++- .../src/dashboard/dashboard-server.ts | 77 ++- .../dashboard/react/InspectorDashboard.tsx | 4 +- .../react/components/ConnectionBar.tsx | 473 ++++++++++++++---- .../dashboard/react/hooks/useConnections.ts | 28 +- .../dashboard/react/hooks/useServerHistory.ts | 33 +- packages/inspector/src/dual-server.ts | 6 +- packages/inspector/src/standalone-server.ts | 7 +- packages/inspector/src/tools/connect.ts | 106 +++- .../inspector/src/types/connection-types.ts | 5 +- .../inspector/tests/call-tool-errors.test.ts | 2 +- .../inspector/tests/call-tool-widget.test.ts | 2 +- .../tests/connection-extended.test.ts | 10 +- .../tests/connection-registry.test.ts | 35 +- .../tests/connection-target-schema.test.ts | 20 +- packages/inspector/tests/connection.test.ts | 25 +- .../tests/console-logs-standalone.test.ts | 2 +- packages/inspector/tests/console-logs.test.ts | 2 +- .../tests/dashboard-connections.test.ts | 11 +- .../tests/dom-events-full-flow.test.ts | 5 +- .../inspector/tests/get-widget-state.test.ts | 4 +- packages/inspector/tests/history.test.ts | 15 +- .../tests/multi-connection-tools.test.ts | 69 ++- .../tests/preview-ui-standalone.test.ts | 2 +- packages/inspector/tests/preview-ui.test.ts | 2 +- .../inspector/tests/prompts-resources.test.ts | 2 +- .../screenshot-widget-standalone.test.ts | 2 +- .../inspector/tests/screenshot-widget.test.ts | 2 +- packages/inspector/tests/test-suite.test.ts | 2 +- packages/inspector/tests/test-utils.ts | 5 +- ...test-widget-interaction-standalone.test.ts | 2 +- .../tests/test-widget-interaction.test.ts | 2 +- packages/inspector/tests/tools.test.ts | 36 +- .../inspector/tests/ui-inspection.test.ts | 28 +- packages/inspector/tests/ui-rendering.test.ts | 32 +- .../inspector/tests/widget-control.test.ts | 34 +- packages/inspector/tests/widget-query.test.ts | 6 +- .../tests/widget-snapshot-diff.test.ts | 4 +- .../inspector/tests/widget-snapshot.test.ts | 4 +- packages/testing/src/eval/mcp/evaluator.ts | 11 +- packages/testing/src/index.ts | 2 + packages/testing/src/server/test-client.ts | 52 +- packages/testing/src/types.ts | 22 + packages/testing/src/ui/test-environment.ts | 2 +- .../tests/unit/server/test-client.test.ts | 8 +- 53 files changed, 1037 insertions(+), 364 deletions(-) diff --git a/examples/minimal/tests/advanced-features.test.ts b/examples/minimal/tests/advanced-features.test.ts index 2b6a1b39..e6ee5804 100644 --- a/examples/minimal/tests/advanced-features.test.ts +++ b/examples/minimal/tests/advanced-features.test.ts @@ -393,9 +393,12 @@ describe("Advanced Features", () => { const server = await startTestServer(app, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, { - trackHistory: true, - }); + const client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v1/mcp` }, + { + trackHistory: true, + } + ); await client.callTool("greet", { name: "History1" }); await client.callTool("greet", { name: "History2" }); @@ -418,7 +421,10 @@ describe("Advanced Features", () => { const testPort = 3014; const server = await startTestServer(app, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`); + const client = await createTestClient({ + transport: "http", + url: `http://localhost:${testPort}/v1/mcp`, + }); const tools = await client.listTools(); expect(tools.some((t) => t.name === "greet")).toBe(true); @@ -435,9 +441,12 @@ describe("Advanced Features", () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Client with short timeout - const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, { - timeout: 5000, // 5 second timeout - }); + const client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v1/mcp` }, + { + timeout: 5000, // 5 second timeout + } + ); // Should complete within timeout const result = await client.callTool("greet", { name: "Timeout" }); diff --git a/examples/minimal/tests/greet-v1.test.ts b/examples/minimal/tests/greet-v1.test.ts index 5ee733db..91f4886c 100644 --- a/examples/minimal/tests/greet-v1.test.ts +++ b/examples/minimal/tests/greet-v1.test.ts @@ -16,9 +16,12 @@ describe("Greet Tool V1", () => { const server = await startTestServer(app, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, { - trackHistory: true, - }); + const client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v1/mcp` }, + { + trackHistory: true, + } + ); env = { server, diff --git a/examples/minimal/tests/greet-v2.test.ts b/examples/minimal/tests/greet-v2.test.ts index ef76fb6b..40d34651 100644 --- a/examples/minimal/tests/greet-v2.test.ts +++ b/examples/minimal/tests/greet-v2.test.ts @@ -16,9 +16,12 @@ describe("Greet Tool V2", () => { const server = await startTestServer(app, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const client = await createTestClient(`http://localhost:${testPort}/v2/mcp`, { - trackHistory: true, - }); + const client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v2/mcp` }, + { + trackHistory: true, + } + ); env = { server, diff --git a/examples/minimal/tests/integration.test.ts b/examples/minimal/tests/integration.test.ts index d72be790..cd8762d4 100644 --- a/examples/minimal/tests/integration.test.ts +++ b/examples/minimal/tests/integration.test.ts @@ -15,10 +15,13 @@ describe("Minimal Example Integration", () => { const server = await startTestServer(app, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, { - trackHistory: true, - timeout: 10000, - }); + const client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v1/mcp` }, + { + trackHistory: true, + timeout: 10000, + } + ); env = { server, diff --git a/examples/minimal/tests/integration/versioning.test.ts b/examples/minimal/tests/integration/versioning.test.ts index 892230f9..d74ac70d 100644 --- a/examples/minimal/tests/integration/versioning.test.ts +++ b/examples/minimal/tests/integration/versioning.test.ts @@ -17,13 +17,19 @@ describe("Versioning", () => { mainServer = await startTestServer(app as unknown, { port: testPort }); await new Promise((resolve) => setTimeout(resolve, 100)); - const v1Client = await createTestClient(`http://localhost:${testPort}/v1/mcp`, { - trackHistory: true, - }); - - const v2Client = await createTestClient(`http://localhost:${testPort}/v2/mcp`, { - trackHistory: true, - }); + const v1Client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v1/mcp` }, + { + trackHistory: true, + } + ); + + const v2Client = await createTestClient( + { transport: "http", url: `http://localhost:${testPort}/v2/mcp` }, + { + trackHistory: true, + } + ); v1Env = { server: mainServer, diff --git a/examples/weather-app/tests/integration/server.test.ts b/examples/weather-app/tests/integration/server.test.ts index efd0445a..7606fdb8 100644 --- a/examples/weather-app/tests/integration/server.test.ts +++ b/examples/weather-app/tests/integration/server.test.ts @@ -17,10 +17,13 @@ describe("Weather App MCP Server", () => { const server = await startTestServer(app, { port: 0 }); await new Promise((r) => setTimeout(r, 100)); - const client = await createTestClient(server.mcpUrl, { - trackHistory: true, - timeout: 15000, - }); + const client = await createTestClient( + { transport: "http", url: server.mcpUrl }, + { + trackHistory: true, + timeout: 15000, + } + ); env = { server, diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 022183d2..1270bb98 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -556,7 +556,7 @@ describe("${name} MCP Server", () => { const server = await startTestServer(app, { port: 0 }); await new Promise((r) => setTimeout(r, 100)); - const client = await createTestClient(server.mcpUrl, { + const client = await createTestClient({ transport: "http", url: server.mcpUrl }, { trackHistory: true, timeout: 10000, }); @@ -1106,7 +1106,7 @@ describe("${name} MCP Server", () => { const server = await startTestServer(app, { port: 0 }); await new Promise((r) => setTimeout(r, 100)); - const client = await createTestClient(server.mcpUrl, { + const client = await createTestClient({ transport: "http", url: server.mcpUrl }, { trackHistory: true, timeout: 10000, }); diff --git a/packages/inspector/src/connection-registry.ts b/packages/inspector/src/connection-registry.ts index 1551f1a6..b54a02d6 100644 --- a/packages/inspector/src/connection-registry.ts +++ b/packages/inspector/src/connection-registry.ts @@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; +import type { ConnectionParams } from "@mcp-apps-kit/testing"; import { ConnectionManager } from "./connection"; import type { ConnectOptions, ConnectionStatusOutput, InspectorServerOptions } from "./types"; @@ -61,12 +62,12 @@ export class ConnectionRegistry extends EventEmitter { /** * Create a new connection and connect to the target server. * - * @param url - MCP server URL to connect to. + * @param params - Connection parameters (transport type + config). * @param options - Connection options passed to the ConnectionManager. * @returns The new connection id and manager instance. */ async createConnection( - url: string, + params: ConnectionParams, options?: ConnectOptions ): Promise<{ id: string; connectionManager: ConnectionManager }> { if (this.connections.size >= this.maxConnections) { @@ -80,7 +81,7 @@ export class ConnectionRegistry extends EventEmitter { }); try { - await connectionManager.connect(url, options); + await connectionManager.connect(params, options); } catch (error) { try { await connectionManager.disconnect(); diff --git a/packages/inspector/src/connection.ts b/packages/inspector/src/connection.ts index a2cb8945..7b542a0e 100644 --- a/packages/inspector/src/connection.ts +++ b/packages/inspector/src/connection.ts @@ -5,7 +5,12 @@ */ import { EventEmitter } from "node:events"; -import { createTestClient, type TestClient, type ToolCall } from "@mcp-apps-kit/testing"; +import { + createTestClient, + type TestClient, + type ToolCall, + type ConnectionParams, +} from "@mcp-apps-kit/testing"; import type { ConnectionState, ConnectOptions, @@ -105,6 +110,7 @@ function getDefaultEnvironmentState(): EnvironmentState { */ export class ConnectionManager extends EventEmitter { private static idCounter = 0; + private static readonly MAX_RESTART_ATTEMPTS = 3; readonly id: string; @@ -115,8 +121,12 @@ export class ConnectionManager extends EventEmitter { historyEnabled: true, callCount: 0, client: null, + connectionParams: null, }; + private autoRestartAttempts = 0; + private autoRestartTimer: ReturnType | null = null; + private environmentState: EnvironmentState; private readonly maxHistorySize: number; private readonly defaultTimeout: number; @@ -159,7 +169,7 @@ export class ConnectionManager extends EventEmitter { * Connect to a target MCP server */ async connect( - url: string, + params: ConnectionParams, options: ConnectOptions = {} ): Promise<{ serverInfo: ServerInfo | null; @@ -169,11 +179,25 @@ export class ConnectionManager extends EventEmitter { }> { const { trackHistory = true, timeout = this.defaultTimeout } = options; - // Validate URL - try { - new URL(url); - } catch { - throw new Error(`Invalid URL format: '${url}'. Expected format: http(s)://host:port/path`); + // Generate display label + const label = + params.transport === "http" + ? params.url + : `stdio: ${params.command}${params.args?.length ? " " + params.args.join(" ") : ""}`; + + // Validate input at API boundary + if (params.transport === "http") { + try { + new URL(params.url); + } catch { + throw new Error( + `Invalid URL format: '${params.url}'. Expected format: http(s)://host:port/path` + ); + } + } else { + if (!params.command?.trim()) { + throw new Error("stdio transport requires a non-empty command"); + } } // Disconnect existing connection if any @@ -185,13 +209,23 @@ export class ConnectionManager extends EventEmitter { } if (this.debug) { - console.log(`[inspector] Connecting to server: ${url}`); + console.log(`[inspector] Connecting to server: ${label}`); } + // Wire onTransportClose for stdio auto-restart + const onTransportClose = + params.transport === "stdio" + ? () => { + if (!this.state.connected) return; // intentional disconnect + this.handleStdioProcessExit(params, options); + } + : undefined; + // Create test client using @mcp-apps-kit/testing - const client = await createTestClient(url, { + const client = await createTestClient(params, { trackHistory, timeout, + onTransportClose, }); // Get server capabilities by listing tools, resources, prompts @@ -251,15 +285,19 @@ export class ConnectionManager extends EventEmitter { // Update state this.state = { connected: true, - serverUrl: url, + serverUrl: label, serverInfo, historyEnabled: trackHistory, callCount: 0, client, + connectionParams: params, }; + // Reset auto-restart attempts on successful connect + this.autoRestartAttempts = 0; + if (this.debug) { - console.log(`[inspector] Connected to ${url}`); + console.log(`[inspector] Connected to ${label}`); console.log( `[inspector] Tools: ${tools.length}, Resources: ${resources.length}, Prompts: ${prompts.length}` ); @@ -339,6 +377,15 @@ export class ConnectionManager extends EventEmitter { async disconnect(): Promise { const previousUrl = this.state.serverUrl; + // Mark as disconnected BEFORE clearing timer so onclose handler sees it + this.state.connected = false; + + // Clear auto-restart timer + if (this.autoRestartTimer) { + clearTimeout(this.autoRestartTimer); + this.autoRestartTimer = null; + } + // Close all widget sessions await this.widgetSessionManager.closeAllSessions(); @@ -363,6 +410,7 @@ export class ConnectionManager extends EventEmitter { historyEnabled: false, callCount: 0, client: null, + connectionParams: null, }; // Clear cached schema @@ -381,6 +429,40 @@ export class ConnectionManager extends EventEmitter { return previousUrl; } + /** + * Handle unexpected stdio process exit with exponential backoff restart + */ + private handleStdioProcessExit(params: ConnectionParams, options: ConnectOptions): void { + if (this.autoRestartAttempts >= ConnectionManager.MAX_RESTART_ATTEMPTS) { + if (this.debug) { + console.log( + `[inspector] Max auto-restart attempts (${ConnectionManager.MAX_RESTART_ATTEMPTS}) reached, disconnecting` + ); + } + void this.disconnect(); + return; + } + + const delay = 1000 * Math.pow(2, this.autoRestartAttempts); // 1s, 2s, 4s + this.autoRestartAttempts++; + + if (this.debug) { + console.log( + `[inspector] stdio process exited, restarting in ${delay}ms (attempt ${this.autoRestartAttempts}/${ConnectionManager.MAX_RESTART_ATTEMPTS})` + ); + } + + this.autoRestartTimer = setTimeout(() => { + this.autoRestartTimer = null; + this.connect(params, options).catch(() => { + if (this.debug) { + console.log(`[inspector] Auto-restart failed, disconnecting`); + } + void this.disconnect(); + }); + }, delay); + } + /** * Get the current connection state */ diff --git a/packages/inspector/src/dashboard/dashboard-server.ts b/packages/inspector/src/dashboard/dashboard-server.ts index 4c4c0288..2fc0d16c 100644 --- a/packages/inspector/src/dashboard/dashboard-server.ts +++ b/packages/inspector/src/dashboard/dashboard-server.ts @@ -16,6 +16,7 @@ import type { IncomingMessage, ServerResponse } from "http"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import type { ConnectionParams } from "@mcp-apps-kit/testing"; import type { ConnectionManager } from "../connection"; import type { ConnectionRegistry } from "../connection-registry"; import type { InspectorEvent, AgnosticInspectorEvent } from "../types"; @@ -145,6 +146,11 @@ export async function handleDashboardRequest( /** * POST /dashboard/connections — create new connection. + * + * Accepts ConnectionParams body: + * - { transport: "http", url: string } + * - { transport: "stdio", command: string, args?: string[], env?: Record, cwd?: string } + * - { url: string } (backward compat — defaults to transport: "http") */ if (pathname === "/dashboard/connections" && req.method === "POST") { setCorsHeaders(res); @@ -158,36 +164,69 @@ export async function handleDashboardRequest( for await (const chunk of req) { chunks.push(chunk as Buffer); } - const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { url?: string }; - if (!body.url) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing url" })); - return true; - } - try { - const parsedUrl = new URL(body.url); - const allowedProtocols = new Set(["http:", "https:", "ws:", "wss:"]); - if (!allowedProtocols.has(parsedUrl.protocol)) { + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Record; + + // Normalize to ConnectionParams (backward compat: { url } → { transport: "http", url }) + let params: ConnectionParams; + const transport = (body.transport as string | undefined) ?? (body.url ? "http" : undefined); + + if (transport === "stdio") { + // Validate stdio params + const command = body.command; + if (typeof command !== "string" || command.trim().length === 0) { res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: "Unsupported URL protocol. Use http, https, ws, or wss.", - }) - ); + res.end(JSON.stringify({ error: "Missing or empty command for stdio transport" })); return true; } - } catch { + params = { + transport: "stdio", + command: command.trim(), + ...(Array.isArray(body.args) ? { args: body.args as string[] } : {}), + ...(body.env && typeof body.env === "object" + ? { env: body.env as Record } + : {}), + ...(typeof body.cwd === "string" ? { cwd: body.cwd } : {}), + }; + } else if (transport === "http") { + // Validate HTTP/WS URL + const urlStr = body.url; + if (typeof urlStr !== "string" || urlStr.trim().length === 0) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing url" })); + return true; + } + try { + const parsedUrl = new URL(urlStr); + const allowedProtocols = new Set(["http:", "https:", "ws:", "wss:"]); + if (!allowedProtocols.has(parsedUrl.protocol)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "Unsupported URL protocol. Use http, https, ws, or wss.", + }) + ); + return true; + } + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid URL format" })); + return true; + } + params = { transport: "http", url: urlStr }; + } else { res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid URL format" })); + res.end(JSON.stringify({ error: "Missing transport type or url" })); return true; } - const { id, connectionManager: cm } = await registry.createConnection(body.url); + + const { id, connectionManager: cm } = await registry.createConnection(params); const state = cm.getState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ id, - url: body.url, + url: state.serverUrl, + transport: params.transport, serverInfo: state.serverInfo, }) ); diff --git a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx index f3aab845..97c6d8c4 100644 --- a/packages/inspector/src/dashboard/react/InspectorDashboard.tsx +++ b/packages/inspector/src/dashboard/react/InspectorDashboard.tsx @@ -334,8 +334,8 @@ export function InspectorDashboard({ }, []); const handleCreateConnection = useCallback( - async (url: string): Promise => { - const created = await createConnection(url); + async (params: import("@mcp-apps-kit/testing").ConnectionParams): Promise => { + const created = await createConnection(params); if (created) { setIsConnectionFormOpen(false); return true; diff --git a/packages/inspector/src/dashboard/react/components/ConnectionBar.tsx b/packages/inspector/src/dashboard/react/components/ConnectionBar.tsx index 8ebbf83e..c1d695e8 100644 --- a/packages/inspector/src/dashboard/react/components/ConnectionBar.tsx +++ b/packages/inspector/src/dashboard/react/components/ConnectionBar.tsx @@ -6,16 +6,39 @@ * - URL input with autocomplete from server history * - Protocol badge display (ChatGPT Apps, MCP Apps, or none) * - Connect/Disconnect controls + * - Transport selector (HTTP / stdio) */ import React, { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import type { ConnectionParams } from "@mcp-apps-kit/testing"; import type { ServerHistoryEntry } from "../hooks"; +/** + * Parse an environment string (KEY=value pairs separated by commas or newlines) + * into a Record, or undefined if empty. + */ +function parseEnvString(envStr: string): Record | undefined { + const trimmed = envStr.trim(); + if (!trimmed) return undefined; + + const result: Record = {}; + const pairs = trimmed.split(/[,\n]+/); + for (const pair of pairs) { + const eqIndex = pair.indexOf("="); + if (eqIndex > 0) { + const key = pair.slice(0, eqIndex).trim(); + const value = pair.slice(eqIndex + 1).trim(); + if (key) result[key] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + export interface ConnectionBarProps { isOpen: boolean; isCreating: boolean; error: string | null; - onCreateConnection: (url: string) => Promise; + onCreateConnection: (params: ConnectionParams) => Promise; onClose: () => void; getMatchingEntries: (filter: string) => ServerHistoryEntry[]; } @@ -172,6 +195,82 @@ const connectionBarStyles: Record = { whiteSpace: "nowrap", zIndex: 101, }, + transportSelect: { + backgroundColor: "#111111", + color: "#e8e8e8", + border: "none", + borderRight: "1px solid #2d2f2f", + padding: "0.5rem 0.5rem", + fontSize: "0.75rem", + fontFamily: "inherit", + fontWeight: 500, + cursor: "pointer", + outline: "none", + flexShrink: 0, + appearance: "none", + WebkitAppearance: "none", + MozAppearance: "none", + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E\")", + backgroundRepeat: "no-repeat", + backgroundPosition: "right 0.25rem center", + paddingRight: "1rem", + } as React.CSSProperties, + advancedToggle: { + display: "flex", + alignItems: "center", + gap: "0.25rem", + background: "transparent", + border: "none", + color: "#6b7280", + fontSize: "0.6875rem", + cursor: "pointer", + padding: "0.25rem 0", + marginTop: "0.375rem", + }, + advancedRow: { + display: "flex", + gap: "0.5rem", + marginTop: "0.375rem", + }, + advancedInput: { + flex: 1, + backgroundColor: "#111111", + border: "1px solid #2d2f2f", + borderRadius: "6px", + color: "#e8e8e8", + padding: "0.375rem 0.5rem", + fontSize: "0.75rem", + fontFamily: "inherit", + outline: "none", + }, + advancedTextarea: { + flex: 1, + backgroundColor: "#111111", + border: "1px solid #2d2f2f", + borderRadius: "6px", + color: "#e8e8e8", + padding: "0.375rem 0.5rem", + fontSize: "0.75rem", + fontFamily: "inherit", + outline: "none", + resize: "vertical" as const, + minHeight: "2rem", + maxHeight: "4rem", + }, + advancedLabel: { + fontSize: "0.625rem", + color: "#6b7280", + marginBottom: "0.125rem", + textTransform: "uppercase" as const, + letterSpacing: "0.04em", + fontWeight: 500, + }, + advancedField: { + display: "flex", + flexDirection: "column" as const, + flex: 1, + }, }; /** @@ -186,17 +285,27 @@ export function ConnectionBar({ getMatchingEntries, }: ConnectionBarProps): React.ReactElement { const [inputValue, setInputValue] = useState(""); + const [transport, setTransport] = useState<"http" | "stdio">("http"); + const [command, setCommand] = useState(""); + const [stdioArgs, setStdioArgs] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); + const [envVars, setEnvVars] = useState(""); + const [cwd, setCwd] = useState(""); const [isFocused, setIsFocused] = useState(false); const [showDropdown, setShowDropdown] = useState(false); const [hoveredIndex, setHoveredIndex] = useState(-1); const inputRef = useRef(null); const containerRef = useRef(null); + // Determine if the current form is submittable + const canSubmit = transport === "http" ? !!inputValue.trim() : !!command.trim(); + // Get filtered history entries based on input + const filterText = transport === "http" ? inputValue : command; const filteredHistory = useMemo(() => { - if (!inputValue && !isFocused) return []; - return getMatchingEntries(inputValue); - }, [inputValue, isFocused, getMatchingEntries]); + if (!filterText && !isFocused) return []; + return getMatchingEntries(filterText); + }, [filterText, isFocused, getMatchingEntries]); // Show dropdown when focused and there are entries useEffect(() => { @@ -215,30 +324,71 @@ export function ConnectionBar({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + const buildParams = useCallback((): ConnectionParams | null => { + if (transport === "http") { + const url = inputValue.trim(); + if (!url) return null; + return { transport: "http", url }; + } + const cmd = command.trim(); + if (!cmd) return null; + return { + transport: "stdio", + command: cmd, + ...(stdioArgs.trim() ? { args: stdioArgs.trim().split(/\s+/) } : {}), + ...(() => { + const env = parseEnvString(envVars); + return env ? { env } : {}; + })(), + ...(cwd.trim() ? { cwd: cwd.trim() } : {}), + }; + }, [transport, inputValue, command, stdioArgs, envVars, cwd]); + const handleCreate = useCallback(async () => { - if (!inputValue.trim() || isCreating) return; - const created = await onCreateConnection(inputValue.trim()); + if (isCreating) return; + const params = buildParams(); + if (!params) return; + const created = await onCreateConnection(params); if (created) { setInputValue(""); + setCommand(""); + setStdioArgs(""); + setEnvVars(""); + setCwd(""); + setShowAdvanced(false); setShowDropdown(false); setIsFocused(false); onClose(); } - }, [inputValue, isCreating, onCreateConnection, onClose]); + }, [isCreating, buildParams, onCreateConnection, onClose]); + + const applyHistoryEntry = useCallback((entry: ServerHistoryEntry): ConnectionParams => { + if (entry.transport === "stdio" && entry.command) { + setTransport("stdio"); + setCommand(entry.command); + setStdioArgs(entry.args?.join(" ") ?? ""); + return { + transport: "stdio", + command: entry.command, + ...(entry.args?.length ? { args: entry.args } : {}), + }; + } + setTransport("http"); + setInputValue(entry.url); + return { transport: "http", url: entry.url }; + }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && showDropdown && hoveredIndex >= 0) { - // Handle dropdown selection first (before generic connect) e.preventDefault(); const entry = filteredHistory[hoveredIndex]; if (entry) { - setInputValue(entry.url); + const params = applyHistoryEntry(entry); setShowDropdown(false); - void onCreateConnection(entry.url); + void onCreateConnection(params); } } else if (e.key === "Enter") { - // Fallback: create when no dropdown selection void handleCreate(); } else if (e.key === "Escape") { setShowDropdown(false); @@ -251,16 +401,23 @@ export function ConnectionBar({ setHoveredIndex((prev) => Math.max(prev - 1, 0)); } }, - [handleCreate, showDropdown, hoveredIndex, filteredHistory, onCreateConnection] + [ + handleCreate, + showDropdown, + hoveredIndex, + filteredHistory, + onCreateConnection, + applyHistoryEntry, + ] ); const handleSelectHistory = useCallback( (entry: ServerHistoryEntry) => { - setInputValue(entry.url); + const params = applyHistoryEntry(entry); setShowDropdown(false); - void onCreateConnection(entry.url); + void onCreateConnection(params); }, - [onCreateConnection] + [onCreateConnection, applyHistoryEntry] ); if (!isOpen) { @@ -272,102 +429,218 @@ export function ConnectionBar({ {/* Inject keyframe animation for spinner */} -
- {/* URL Input */} - setInputValue(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setTimeout(() => setIsFocused(false), 200)} - onKeyDown={handleKeyDown} - placeholder="(Connect your Agent)" - /> - - {/* Action Button */} - - + {/* Action Button */} + + + +
+ + {/* Advanced Settings (stdio only) */} + {transport === "stdio" && ( + <> + + {showAdvanced && ( +
+
+ +