diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 9f40165..ee298cc 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -42,6 +42,11 @@ import { type ProfileCommandContext, validateProfileLoad, } from "./commands/profile"; +import { + handleMcpAdd, + handleMcpUsage, + type McpCommandContext, +} from "./commands/mcp"; import { AgentSelector } from "./components/AgentSelector"; import { ApprovalDialog } from "./components/ApprovalDialogRich"; import { AssistantMessage } from "./components/AssistantMessageRich"; @@ -51,6 +56,7 @@ import { ErrorMessage } from "./components/ErrorMessageRich"; import { FeedbackDialog } from "./components/FeedbackDialog"; import { HelpDialog } from "./components/HelpDialog"; import { Input } from "./components/InputRich"; +import { McpSelector } from "./components/McpSelector"; import { MemoryViewer } from "./components/MemoryViewer"; import { MessageSearch } from "./components/MessageSearch"; import { ModelSelector } from "./components/ModelSelector"; @@ -410,6 +416,7 @@ export default function App({ | "feedback" | "memory" | "pin" + | "mcp" | "help" | null; const [activeOverlay, setActiveOverlay] = useState(null); @@ -1756,6 +1763,35 @@ export default function App({ return { submitted: true }; } + // Special handling for /mcp command - manage MCP servers + if (msg.trim().startsWith("/mcp")) { + const mcpCtx: McpCommandContext = { + buffersRef, + refreshDerived, + setCommandRunning, + }; + + // Check for subcommand by looking at the first word after /mcp + const afterMcp = msg.trim().slice(4).trim(); // Remove "/mcp" prefix + const firstWord = afterMcp.split(/\s+/)[0]?.toLowerCase(); + + // /mcp - open MCP server selector + if (!firstWord) { + setActiveOverlay("mcp"); + return { submitted: true }; + } + + // /mcp add --transport [options] + if (firstWord === "add") { + // Pass the full command string after "add" to preserve quotes + const afterAdd = afterMcp.slice(firstWord.length).trim(); + await handleMcpAdd(mcpCtx, msg, afterAdd); + return { submitted: true }; + } + + // Unknown subcommand + handleMcpUsage(mcpCtx, msg); + // Special handling for /help command - opens help dialog if (trimmed === "/help") { setActiveOverlay("help"); @@ -4610,6 +4646,29 @@ Plan file path: ${planFilePath}`; /> )} + {/* MCP Server Selector - conditionally mounted as overlay */} + {activeOverlay === "mcp" && ( + { + // Close overlay and prompt user to use /mcp add command + closeOverlay(); + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: "/mcp", + output: "Use /mcp add --transport [...] to add a new server", + phase: "finished", + success: true, + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + }} + onCancel={closeOverlay} + /> + )} + {/* Help Dialog - conditionally mounted as overlay */} {activeOverlay === "help" && } diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..e125495 --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,358 @@ +// src/cli/commands/mcp.ts +// MCP server command handlers + +import type { + CreateStreamableHTTPMcpServer, + CreateSseMcpServer, + CreateStdioMcpServer, +} from "@letta-ai/letta-client/resources/mcp-servers/mcp-servers"; +import { getClient } from "../../agent/client"; +import type { Buffers, Line } from "../helpers/accumulator"; +import { formatErrorDetails } from "../helpers/errorFormatter"; + +// tiny helper for unique ids +function uid(prefix: string) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +// Helper type for command result +type CommandLine = Extract; + +// Context passed to MCP handlers +export interface McpCommandContext { + buffersRef: { current: Buffers }; + refreshDerived: () => void; + setCommandRunning: (running: boolean) => void; +} + +// Helper to add a command result to buffers +export function addCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): string { + const cmdId = uid("cmd"); + const line: CommandLine = { + kind: "command", + id: cmdId, + input, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + buffersRef.current.order.push(cmdId); + refreshDerived(); + return cmdId; +} + +// Helper to update an existing command result +export function updateCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + cmdId: string, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): void { + const line: CommandLine = { + kind: "command", + id: cmdId, + input, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + refreshDerived(); +} + +// Helper to parse command line arguments respecting quoted strings +function parseCommandArgs(commandStr: string): string[] { + const args: string[] = []; + let current = ""; + let inQuotes = false; + let quoteChar = ""; + + for (let i = 0; i < commandStr.length; i++) { + const char = commandStr[i]; + if (!char) continue; // Skip if undefined (shouldn't happen but type safety) + + if ((char === '"' || char === "'") && !inQuotes) { + // Start of quoted string + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + // End of quoted string + inQuotes = false; + quoteChar = ""; + } else if (/\s/.test(char) && !inQuotes) { + // Whitespace outside quotes - end of argument + if (current) { + args.push(current); + current = ""; + } + } else { + // Regular character or whitespace inside quotes + current += char; + } + } + + // Add final argument if any + if (current) { + args.push(current); + } + + return args; +} + +// Parse /mcp add args +interface McpAddArgs { + transport: "http" | "sse" | "stdio"; + name: string; + url: string | null; + command: string | null; + args: string[]; + headers: Record; + authToken: string | null; +} + +function parseMcpAddArgs(parts: string[]): McpAddArgs | null { + // Expected format: add --transport [--header "key: value"] + let transport: "http" | "sse" | "stdio" | null = null; + let name: string | null = null; + let url: string | null = null; + let command: string | null = null; + let args: string[] = []; + const headers: Record = {}; + let authToken: string | null = null; + + let i = 0; + while (i < parts.length) { + const part = parts[i]; + + if (part === "--transport" || part === "-t") { + i++; + const transportValue = parts[i]?.toLowerCase(); + if (transportValue === "http" || transportValue === "streamable_http") { + transport = "http"; + } else if (transportValue === "sse") { + transport = "sse"; + } else if (transportValue === "stdio") { + transport = "stdio"; + } + i++; + } else if (part === "--header" || part === "-h") { + i++; + const headerValue = parts[i]; + if (headerValue) { + // Parse "key: value" or "key=value" + const colonMatch = headerValue.match(/^([^:]+):\s*(.+)$/); + const equalsMatch = headerValue.match(/^([^=]+)=(.+)$/); + if (colonMatch && colonMatch[1] && colonMatch[2]) { + headers[colonMatch[1].trim()] = colonMatch[2].trim(); + } else if (equalsMatch && equalsMatch[1] && equalsMatch[2]) { + headers[equalsMatch[1].trim()] = equalsMatch[2].trim(); + } + } + i++; + } else if (part === "--auth" || part === "-a") { + i++; + authToken = parts[i] || null; + i++; + } else if (!name) { + name = part || null; + i++; + } else if (!url && transport !== "stdio") { + url = part || null; + i++; + } else if (!command && transport === "stdio") { + command = part || null; + i++; + } else if (transport === "stdio" && part) { + // Collect remaining parts as args for stdio + args.push(part); + i++; + } else { + i++; + } + } + + if (!transport || !name) { + return null; + } + + if (transport !== "stdio" && !url) { + return null; + } + + if (transport === "stdio" && !command) { + return null; + } + + return { + transport, + name, + url: url || null, + command: command || null, + args, + headers, + authToken: authToken || null + }; +} + +// /mcp add --transport [options] +export async function handleMcpAdd( + ctx: McpCommandContext, + msg: string, + commandStr: string, +): Promise { + // Parse the full command string respecting quotes + const parts = parseCommandArgs(commandStr); + const args = parseMcpAddArgs(parts); + + if (!args) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Usage: /mcp add --transport [--header \"key: value\"] [--auth token]\n\nExamples:\n /mcp add --transport http notion https://mcp.notion.com/mcp\n /mcp add --transport http secure-api https://api.example.com/mcp --header \"Authorization: Bearer token\"", + false, + ); + return; + } + + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + `Creating MCP server "${args.name}"...`, + false, + "running", + ); + + ctx.setCommandRunning(true); + + try { + const client = await getClient(); + + let config: + | CreateStreamableHTTPMcpServer + | CreateSseMcpServer + | CreateStdioMcpServer; + + if (args.transport === "http") { + if (!args.url) { + throw new Error("URL is required for HTTP transport"); + } + config = { + mcp_server_type: "streamable_http", + server_url: args.url, + auth_token: args.authToken, + custom_headers: Object.keys(args.headers).length > 0 ? args.headers : null, + }; + } else if (args.transport === "sse") { + if (!args.url) { + throw new Error("URL is required for SSE transport"); + } + config = { + mcp_server_type: "sse", + server_url: args.url, + auth_token: args.authToken, + custom_headers: Object.keys(args.headers).length > 0 ? args.headers : null, + }; + } else { + // stdio + if (!args.command) { + throw new Error("Command is required for stdio transport"); + } + config = { + mcp_server_type: "stdio", + command: args.command, + args: args.args, + }; + } + + const server = await client.mcpServers.create({ + server_name: args.name, + config, + }); + + if (!server.id) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Created MCP server "${args.name}" but server ID not available`, + false, + ); + return; + } + + // Auto-refresh to fetch tools from the MCP server + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Created MCP server "${args.name}" (${server.mcp_server_type})\nID: ${server.id}\nFetching tools from server...`, + false, + "running", + ); + + try { + await client.mcpServers.refresh(server.id); + + // Get tool count + const tools = await client.mcpServers.tools.list(server.id); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Created MCP server "${args.name}" (${server.mcp_server_type})\nID: ${server.id}\nLoaded ${tools.length} tool${tools.length === 1 ? "" : "s"} from server`, + true, + ); + } catch (refreshErr) { + // If refresh fails, still show success but warn about tools + const errorMsg = refreshErr instanceof Error ? refreshErr.message : "Unknown error"; + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Created MCP server "${args.name}" (${server.mcp_server_type})\nID: ${server.id}\nWarning: Could not fetch tools - ${errorMsg}\nUse /mcp and press R to refresh manually.`, + true, + ); + } + } catch (error) { + const errorDetails = formatErrorDetails(error, ""); + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Failed: ${errorDetails}`, + false, + ); + } finally { + ctx.setCommandRunning(false); + } +} + +// Show usage help +export function handleMcpUsage(ctx: McpCommandContext, msg: string): void { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Usage: /mcp [add ...]\n /mcp - list MCP servers\n /mcp add --transport [...] - add a new server\n\nExamples:\n /mcp add --transport http notion https://mcp.notion.com/mcp\n /mcp add --transport http api https://api.example.com --header \"Authorization: Bearer token\"", + false, + ); +} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 6d962ef..04c93e8 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -195,6 +195,13 @@ export const commands: Record = { return "Fetching usage statistics..."; }, }, + "/mcp": { + desc: "Manage MCP servers", + handler: () => { + // Handled specially in App.tsx to show MCP server selector + return "Opening MCP server manager..."; + }, + }, "/help": { desc: "Show available commands", handler: () => { diff --git a/src/cli/components/McpSelector.tsx b/src/cli/components/McpSelector.tsx new file mode 100644 index 0000000..ee7a99a --- /dev/null +++ b/src/cli/components/McpSelector.tsx @@ -0,0 +1,688 @@ +import type { + McpServerListResponse, + StreamableHTTPMcpServer, + SseMcpServer, + StdioMcpServer, +} from "@letta-ai/letta-client/resources/mcp-servers/mcp-servers"; +import type { Tool } from "@letta-ai/letta-client/resources/tools"; +import { Box, Text, useInput } from "ink"; +import { memo, useCallback, useEffect, useState } from "react"; +import { getClient } from "../../agent/client"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +interface McpSelectorProps { + agentId: string; + onAdd: () => void; + onCancel: () => void; +} + +type McpServer = StreamableHTTPMcpServer | SseMcpServer | StdioMcpServer; + +const DISPLAY_PAGE_SIZE = 5; +const TOOLS_DISPLAY_PAGE_SIZE = 8; + +/** + * Get a display string for the MCP server type + */ +function getServerTypeDisplay(server: McpServer): string { + switch (server.mcp_server_type) { + case "streamable_http": + return "HTTP"; + case "sse": + return "SSE"; + case "stdio": + return "stdio"; + default: + return "unknown"; + } +} + +/** + * Get the server URL or command for display + */ +function getServerTarget(server: McpServer): string { + if ("server_url" in server) { + return server.server_url; + } + if ("command" in server) { + return `${server.command} ${server.args.join(" ")}`; + } + return "unknown"; +} + +/** + * Truncate text with ellipsis if it exceeds width + */ +function truncateText(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth < 10) return text.slice(0, maxWidth); + return `${text.slice(0, maxWidth - 3)}...`; +} + +type Mode = "browsing" | "confirming-delete" | "viewing-tools"; + +export const McpSelector = memo(function McpSelector({ + agentId, + onAdd, + onCancel, +}: McpSelectorProps) { + const terminalWidth = useTerminalWidth(); + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [mode, setMode] = useState("browsing"); + const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0); + const [error, setError] = useState(null); + + // Tools viewing state + const [viewingServer, setViewingServer] = useState(null); + const [tools, setTools] = useState([]); + const [attachedToolIds, setAttachedToolIds] = useState>(new Set()); + const [toolsLoading, setToolsLoading] = useState(false); + const [toolsError, setToolsError] = useState(null); + const [toolsPage, setToolsPage] = useState(0); + const [toolsSelectedIndex, setToolsSelectedIndex] = useState(0); + const [isTogglingTool, setIsTogglingTool] = useState(false); + + // Load MCP servers + const loadServers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const client = await getClient(); + const serverList = await client.mcpServers.list(); + setServers(serverList); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load MCP servers", + ); + setServers([]); + } finally { + setLoading(false); + } + }, []); + + // Load tools for a specific server + const loadTools = useCallback(async (server: McpServer) => { + if (!server.id) { + setToolsError("Server ID not available"); + return; + } + + setToolsLoading(true); + setToolsError(null); + setViewingServer(server); + setMode("viewing-tools"); + + try { + const client = await getClient(); + + // Fetch MCP server tools + const toolsList = await client.mcpServers.tools.list(server.id); + + // If no tools found, might need to refresh from server + if (toolsList.length === 0) { + setToolsError( + "No tools found. The server may need to be refreshed. Press R to sync tools from the MCP server." + ); + } + + setTools(toolsList); + + // Fetch agent's current tools to check which are attached + const agent = await client.agents.retrieve(agentId); + const agentToolIds = new Set(agent.tools?.map(t => t.id) || []); + setAttachedToolIds(agentToolIds); + + setToolsPage(0); + setToolsSelectedIndex(0); + } catch (err) { + setToolsError( + err instanceof Error ? err.message : "Failed to load tools", + ); + setTools([]); + } finally { + setToolsLoading(false); + } + }, [agentId]); + + // Refresh tools from MCP server + const refreshToolsFromServer = useCallback(async () => { + if (!viewingServer?.id) return; + + setToolsLoading(true); + setToolsError(null); + + try { + const client = await getClient(); + + // Call refresh endpoint to sync tools from the MCP server + await client.mcpServers.refresh(viewingServer.id, { agent_id: agentId }); + + // Reload tools list + const toolsList = await client.mcpServers.tools.list(viewingServer.id); + setTools(toolsList); + + // Refresh agent's current tools + const agent = await client.agents.retrieve(agentId); + const agentToolIds = new Set(agent.tools?.map(t => t.id) || []); + setAttachedToolIds(agentToolIds); + + setToolsPage(0); + setToolsSelectedIndex(0); + + // Clear error if successful + if (toolsList.length === 0) { + setToolsError("Server refreshed but no tools available."); + } + } catch (err) { + setToolsError( + err instanceof Error ? `Failed to refresh: ${err.message}` : "Failed to refresh tools", + ); + } finally { + setToolsLoading(false); + } + }, [agentId, viewingServer]); + + // Toggle tool attachment + const toggleTool = useCallback(async (tool: Tool) => { + setIsTogglingTool(true); + try { + const client = await getClient(); + const isAttached = attachedToolIds.has(tool.id); + + if (isAttached) { + // Detach tool + await client.agents.tools.detach(tool.id, { agent_id: agentId }); + } else { + // Attach tool + await client.agents.tools.attach(tool.id, { agent_id: agentId }); + } + + // Fetch agent's current tools to get accurate total count + const agent = await client.agents.retrieve(agentId); + const agentToolIds = new Set(agent.tools?.map(t => t.id) || []); + setAttachedToolIds(agentToolIds); + } catch (err) { + setToolsError( + err instanceof Error ? err.message : "Failed to toggle tool attachment", + ); + } finally { + setIsTogglingTool(false); + } + }, [agentId, attachedToolIds]); + + // Attach all tools + const attachAllTools = useCallback(async () => { + setIsTogglingTool(true); + try { + const client = await getClient(); + + // Attach tools that aren't already attached + const unattachedTools = tools.filter(t => !attachedToolIds.has(t.id)); + await Promise.all( + unattachedTools.map(tool => + client.agents.tools.attach(tool.id, { agent_id: agentId }) + ) + ); + + // Fetch agent's current tools to get accurate total count + const agent = await client.agents.retrieve(agentId); + const agentToolIds = new Set(agent.tools?.map(t => t.id) || []); + setAttachedToolIds(agentToolIds); + } catch (err) { + setToolsError( + err instanceof Error ? err.message : "Failed to attach all tools", + ); + } finally { + setIsTogglingTool(false); + } + }, [agentId, tools, attachedToolIds]); + + // Detach all tools + const detachAllTools = useCallback(async () => { + setIsTogglingTool(true); + try { + const client = await getClient(); + + // Detach only the tools from this server that are currently attached + const attachedTools = tools.filter(t => attachedToolIds.has(t.id)); + await Promise.all( + attachedTools.map(tool => + client.agents.tools.detach(tool.id, { agent_id: agentId }) + ) + ); + + // Fetch agent's current tools to get accurate total count + const agent = await client.agents.retrieve(agentId); + const agentToolIds = new Set(agent.tools?.map(t => t.id) || []); + setAttachedToolIds(agentToolIds); + } catch (err) { + setToolsError( + err instanceof Error ? err.message : "Failed to detach all tools", + ); + } finally { + setIsTogglingTool(false); + } + }, [agentId, tools, attachedToolIds]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + + // Pagination + const totalPages = Math.ceil(servers.length / DISPLAY_PAGE_SIZE); + const startIndex = currentPage * DISPLAY_PAGE_SIZE; + const pageServers = servers.slice(startIndex, startIndex + DISPLAY_PAGE_SIZE); + + // Get currently selected server + const selectedServer = pageServers[selectedIndex]; + + useInput((input, key) => { + if (loading) return; + + // Handle delete confirmation mode + if (mode === "confirming-delete") { + if (key.upArrow || key.downArrow) { + setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0)); + } else if (key.return) { + if (deleteConfirmIndex === 0 && selectedServer) { + // Yes - delete server + (async () => { + try { + const client = await getClient(); + if (selectedServer.id) { + await client.mcpServers.delete(selectedServer.id); + await loadServers(); + // Reset selection if needed + if (pageServers.length === 1 && currentPage > 0) { + setCurrentPage((prev) => prev - 1); + } + setSelectedIndex(0); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to delete MCP server", + ); + } + setMode("browsing"); + })(); + } else { + // No - cancel + setMode("browsing"); + } + } else if (key.escape) { + setMode("browsing"); + } + return; + } + + // Handle viewing tools mode + if (mode === "viewing-tools") { + if (isTogglingTool) return; // Prevent input during toggle + + const toolsTotalPages = Math.ceil(tools.length / TOOLS_DISPLAY_PAGE_SIZE); + const toolsStartIndex = toolsPage * TOOLS_DISPLAY_PAGE_SIZE; + const pageTools = tools.slice(toolsStartIndex, toolsStartIndex + TOOLS_DISPLAY_PAGE_SIZE); + const selectedTool = pageTools[toolsSelectedIndex]; + + if (key.upArrow) { + if (toolsSelectedIndex === 0 && toolsPage > 0) { + // At top of page, go to previous page + setToolsPage((prev) => prev - 1); + setToolsSelectedIndex(TOOLS_DISPLAY_PAGE_SIZE - 1); + } else { + setToolsSelectedIndex((prev) => Math.max(0, prev - 1)); + } + } else if (key.downArrow) { + if (toolsSelectedIndex === pageTools.length - 1 && toolsPage < toolsTotalPages - 1) { + // At bottom of page, go to next page + setToolsPage((prev) => prev + 1); + setToolsSelectedIndex(0); + } else { + setToolsSelectedIndex((prev) => Math.min(pageTools.length - 1, prev + 1)); + } + } else if ((key.return || input === " ") && selectedTool) { + // Space or Enter to toggle selected tool + toggleTool(selectedTool); + } else if (input === "a" || input === "A") { + // Attach all tools + attachAllTools(); + } else if (input === "d" || input === "D") { + // Detach all tools + detachAllTools(); + } else if (input === "r" || input === "R") { + // Refresh tools from MCP server + refreshToolsFromServer(); + } else if (key.escape) { + // Go back to server list + setMode("browsing"); + setViewingServer(null); + setTools([]); + setToolsError(null); + } + return; + } + + // Browsing mode + if (key.upArrow) { + if (selectedIndex === 0 && currentPage > 0) { + // At top of page, go to previous page + setCurrentPage((prev) => prev - 1); + setSelectedIndex(DISPLAY_PAGE_SIZE - 1); + } else { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } + } else if (key.downArrow) { + if (selectedIndex === pageServers.length - 1 && currentPage < totalPages - 1) { + // At bottom of page, go to next page + setCurrentPage((prev) => prev + 1); + setSelectedIndex(0); + } else { + setSelectedIndex((prev) => Math.min(pageServers.length - 1, prev + 1)); + } + } else if (key.return) { + // Enter to view tools for selected server + if (selectedServer) { + loadTools(selectedServer); + } + } else if (input === "a" || input === "A") { + // 'a' to add new server + onAdd(); + } else if (key.escape) { + onCancel(); + } else if (input === "d" || input === "D") { + if (selectedServer) { + setMode("confirming-delete"); + setDeleteConfirmIndex(1); // Default to "No" + } + } else if (input === "r" || input === "R") { + // Refresh server list + loadServers(); + } + }); + + // Tools viewing UI + if (mode === "viewing-tools" && viewingServer) { + const toolsTotalPages = Math.ceil(tools.length / TOOLS_DISPLAY_PAGE_SIZE); + const toolsStartIndex = toolsPage * TOOLS_DISPLAY_PAGE_SIZE; + const pageTools = tools.slice(toolsStartIndex, toolsStartIndex + TOOLS_DISPLAY_PAGE_SIZE); + + return ( + + + + Tools for {viewingServer.server_name} + + + + {/* Loading state */} + {toolsLoading && ( + + + {tools.length > 0 ? "Refreshing tools..." : "Loading tools..."} + + {tools.length === 0 && ( + + This may take a moment on first load + + )} + + )} + + {/* Error state */} + {!toolsLoading && toolsError && ( + + {toolsError} + + R refresh from server · Esc back + + + )} + + {/* Empty state */} + {!toolsLoading && !toolsError && tools.length === 0 && ( + + No tools available for this server. + Press R to sync tools from the MCP server. + + R refresh · Esc back + + + )} + + {/* Tools list */} + {!toolsLoading && !toolsError && tools.length > 0 && ( + + {pageTools.map((tool, index) => { + const isSelected = index === toolsSelectedIndex; + const isAttached = attachedToolIds.has(tool.id); + const toolName = tool.name || "Unnamed tool"; + const toolDesc = tool.description || "No description"; + const statusIndicator = isAttached ? "✓" : " "; + + return ( + + {/* Row 1: Selection indicator, attachment status, and tool name */} + + + {isSelected ? ">" : " "} + + + + [{statusIndicator}] + + + + {toolName} + + + {/* Row 2: Description */} + + + {truncateText(toolDesc, terminalWidth - 4)} + + + + ); + })} + + )} + + {/* Footer with pagination and controls */} + {!toolsLoading && !toolsError && tools.length > 0 && (() => { + const attachedFromThisServer = tools.filter(t => attachedToolIds.has(t.id)).length; + return ( + + + + {toolsTotalPages > 1 && `Page ${toolsPage + 1}/${toolsTotalPages} · `} + {attachedFromThisServer}/{tools.length} attached from server · {attachedToolIds.size} total on agent + + + + + ↑↓ navigate · Space/Enter toggle · A attach all · D detach all · R refresh · Esc back + + + + ); + })()} + + ); + } + + // Delete confirmation UI + if (mode === "confirming-delete" && selectedServer) { + const options = ["Yes, delete", "No, cancel"]; + return ( + + + + Delete MCP Server + + + + Delete "{selectedServer.server_name}"? + + + {options.map((option, index) => { + const isSelected = index === deleteConfirmIndex; + return ( + + + {isSelected ? ">" : " "} {option} + + + ); + })} + + + ); + } + + // Main browsing UI + return ( + + + + MCP Servers + + + + {/* Loading state */} + {loading && ( + + Loading MCP servers... + + )} + + {/* Error state */} + {!loading && error && ( + + Error: {error} + + R refresh · Esc close + + + )} + + {/* Empty state */} + {!loading && !error && servers.length === 0 && ( + + No MCP servers configured. + Press A to add a new server. + + A add · Esc close + + + )} + + {/* Server list */} + {!loading && !error && servers.length > 0 && ( + + {pageServers.map((server, index) => { + const isSelected = index === selectedIndex; + const serverType = getServerTypeDisplay(server); + const target = getServerTarget(server); + + // Calculate available width for target display + const nameLen = server.server_name.length; + const typeLen = serverType.length; + const fixedChars = 2 + 3 + 3 + typeLen; // "> " + " · " + " · " + type + const availableForTarget = Math.max( + 20, + terminalWidth - nameLen - fixedChars, + ); + const displayTarget = truncateText(target, availableForTarget); + + return ( + + {/* Row 1: Selection indicator, name, type, and ID */} + + + {isSelected ? ">" : " "} + + + + {server.server_name} + + + {" "} + · {serverType} · {displayTarget} + + + {/* Row 2: Server ID if available */} + {server.id && ( + + + ID: {server.id} + + + )} + + ); + })} + + )} + + {/* Footer with pagination and controls */} + {!loading && !error && servers.length > 0 && ( + + {totalPages > 1 && ( + + + Page {currentPage + 1}/{totalPages} + + + )} + + + ↑↓ navigate · Enter view tools · A add · D delete · R refresh · Esc close + + + + )} + + ); +}); + +McpSelector.displayName = "McpSelector";