feat: add /mcp (#302)
This commit is contained in:
@@ -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<ActiveOverlay>(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 <type> <name> <url/command> [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" && (
|
||||
<McpSelector
|
||||
agentId={agentId}
|
||||
onAdd={() => {
|
||||
// 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 <http|sse|stdio> <name> <url|command> [...] 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" && <HelpDialog onClose={closeOverlay} />}
|
||||
|
||||
|
||||
358
src/cli/commands/mcp.ts
Normal file
358
src/cli/commands/mcp.ts
Normal file
@@ -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<Line, { kind: "command" }>;
|
||||
|
||||
// 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<string, string>;
|
||||
authToken: string | null;
|
||||
}
|
||||
|
||||
function parseMcpAddArgs(parts: string[]): McpAddArgs | null {
|
||||
// Expected format: add --transport <type> <name> <url/command> [--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<string, string> = {};
|
||||
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 <type> <name> <url/command> [options]
|
||||
export async function handleMcpAdd(
|
||||
ctx: McpCommandContext,
|
||||
msg: string,
|
||||
commandStr: string,
|
||||
): Promise<void> {
|
||||
// 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 <http|sse|stdio> <name> <url|command> [--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 <http|sse|stdio> <name> <url|command> [...] - 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,
|
||||
);
|
||||
}
|
||||
@@ -195,6 +195,13 @@ export const commands: Record<string, Command> = {
|
||||
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: () => {
|
||||
|
||||
688
src/cli/components/McpSelector.tsx
Normal file
688
src/cli/components/McpSelector.tsx
Normal file
@@ -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<McpServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>("browsing");
|
||||
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Tools viewing state
|
||||
const [viewingServer, setViewingServer] = useState<McpServer | null>(null);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [attachedToolIds, setAttachedToolIds] = useState<Set<string>>(new Set());
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [toolsError, setToolsError] = useState<string | null>(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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Tools for {viewingServer.server_name}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Loading state */}
|
||||
{toolsLoading && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>
|
||||
{tools.length > 0 ? "Refreshing tools..." : "Loading tools..."}
|
||||
</Text>
|
||||
{tools.length === 0 && (
|
||||
<Text dimColor italic>
|
||||
This may take a moment on first load
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!toolsLoading && toolsError && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">{toolsError}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>R refresh from server · Esc back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!toolsLoading && !toolsError && tools.length === 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>No tools available for this server.</Text>
|
||||
<Text dimColor>Press R to sync tools from the MCP server.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>R refresh · Esc back</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Tools list */}
|
||||
{!toolsLoading && !toolsError && tools.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box
|
||||
key={tool.id}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
{/* Row 1: Selection indicator, attachment status, and tool name */}
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
color={isAttached ? "green" : "gray"}
|
||||
bold={isAttached}
|
||||
>
|
||||
[{statusIndicator}]
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{toolName}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Row 2: Description */}
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
{truncateText(toolDesc, terminalWidth - 4)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer with pagination and controls */}
|
||||
{!toolsLoading && !toolsError && tools.length > 0 && (() => {
|
||||
const attachedFromThisServer = tools.filter(t => attachedToolIds.has(t.id)).length;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{toolsTotalPages > 1 && `Page ${toolsPage + 1}/${toolsTotalPages} · `}
|
||||
{attachedFromThisServer}/{tools.length} attached from server · {attachedToolIds.size} total on agent
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Space/Enter toggle · A attach all · D detach all · R refresh · Esc back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete confirmation UI
|
||||
if (mode === "confirming-delete" && selectedServer) {
|
||||
const options = ["Yes, delete", "No, cancel"];
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
Delete MCP Server
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Delete "{selectedServer.server_name}"?</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === deleteConfirmIndex;
|
||||
return (
|
||||
<Box key={option}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isSelected ? ">" : " "} {option}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Main browsing UI
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text bold color={colors.selector.title}>
|
||||
MCP Servers
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box>
|
||||
<Text dimColor>Loading MCP servers...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Error: {error}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>R refresh · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && servers.length === 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>No MCP servers configured.</Text>
|
||||
<Text dimColor>Press A to add a new server.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>A add · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Server list */}
|
||||
{!loading && !error && servers.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
{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 (
|
||||
<Box
|
||||
key={server.id || server.server_name}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
{/* Row 1: Selection indicator, name, type, and ID */}
|
||||
<Box flexDirection="row">
|
||||
<Text
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{isSelected ? ">" : " "}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? colors.selector.itemHighlighted : undefined
|
||||
}
|
||||
>
|
||||
{server.server_name}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
· {serverType} · {displayTarget}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Row 2: Server ID if available */}
|
||||
{server.id && (
|
||||
<Box flexDirection="row" marginLeft={2}>
|
||||
<Text dimColor italic>
|
||||
ID: {server.id}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer with pagination and controls */}
|
||||
{!loading && !error && servers.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{totalPages > 1 && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Page {currentPage + 1}/{totalPages}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
↑↓ navigate · Enter view tools · A add · D delete · R refresh · Esc close
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
McpSelector.displayName = "McpSelector";
|
||||
Reference in New Issue
Block a user