Files
letta-code/src/cli/commands/mcp.ts

367 lines
9.7 KiB
TypeScript

// src/cli/commands/mcp.ts
// MCP server command handlers
import type {
CreateSseMcpServer,
CreateStdioMcpServer,
CreateStreamableHTTPMcpServer,
} 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;
const 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?.[1] && colonMatch[2]) {
headers[colonMatch[1].trim()] = colonMatch[2].trim();
} else if (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 [subcommand ...]\n" +
" /mcp - Open MCP server manager\n" +
" /mcp add ... - Add a new server (without OAuth)\n" +
" /mcp connect - Interactive wizard with OAuth support\n\n" +
"Examples:\n" +
" /mcp add --transport http notion https://mcp.notion.com/mcp",
false,
);
}