/**
* MCP Client Manager — connect to external MCP servers
*
* Reads mcpServers from ~/.swagmanager/config.json, spawns/connects each,
* and exposes their tools with mcp__{server}__{tool} naming convention.
* Non-fatal: if a server fails to connect, it's skipped with a warning.
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type Anthropic from "@anthropic-ai/sdk";
import { loadConfig } from "./config-store.js";
// ============================================================================
// TYPES
// ============================================================================
interface McpServerConfig {
command?: string;
args?: string[];
env?: Record<string, string>;
transport: "stdio" | "http";
url?: string;
}
interface ConnectedServer {
name: string;
client: Client;
tools: Anthropic.Tool[];
}
// ============================================================================
// MCP CLIENT MANAGER
// ============================================================================
class McpClientManager {
private servers = new Map<string, ConnectedServer>();
private connectPromise: Promise<void> | null = null;
/**
* Connect to all configured MCP servers.
* Non-blocking: failures are logged but don't prevent startup.
*/
async connectAll(): Promise<void> {
if (this.connectPromise) return this.connectPromise;
this.connectPromise = this._connectAll();
return this.connectPromise;
}
private async _connectAll(): Promise<void> {
const config = loadConfig() as Record<string, unknown>;
const mcpServers = (config.mcpServers || {}) as Record<string, McpServerConfig>;
const serverNames = Object.keys(mcpServers);
if (serverNames.length === 0) return;
const results = await Promise.allSettled(
serverNames.map(name => this.connectServer(name, mcpServers[name]))
);
for (let i = 0; i < results.length; i++) {
if (results[i].status === "rejected") {
const err = (results[i] as PromiseRejectedResult).reason;
process.stderr.write(`[mcp] Failed to connect to "${serverNames[i]}": ${err?.message || err}\n`);
}
}
}
private async connectServer(name: string, config: McpServerConfig): Promise<void> {
const client = new Client({
name: "whale-code",
version: "5.1.0",
});
let transport: any;
if (config.transport === "http" && config.url) {
// Dynamically import HTTP transport — handles both new StreamableHTTP and legacy SSE
try {
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
transport = new StreamableHTTPClientTransport(new URL(config.url));
} catch {
// Fallback to SSE for older SDK versions
const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
transport = new SSEClientTransport(new URL(config.url));
}
} else if (config.command) {
transport = new StdioClientTransport({
command: config.command,
args: config.args || [],
env: {
...process.env as Record<string, string>,
...(config.env || {}),
},
// Pipe stderr to avoid corrupting our UI
stderr: "pipe",
});
} else {
throw new Error(`Invalid config: no command or url`);
}
// Connect with a 10s timeout
const connectTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Connection timeout (10s)")), 10_000)
);
await Promise.race([client.connect(transport), connectTimeout]);
// Discover tools
const toolsResult = await client.listTools();
const tools: Anthropic.Tool[] = (toolsResult.tools || []).map(t => ({
name: `mcp__${name}__${t.name}`,
description: `[${name}] ${t.description || t.name}`,
input_schema: (t.inputSchema || { type: "object", properties: {} }) as Anthropic.Tool["input_schema"],
}));
this.servers.set(name, { name, client, tools });
}
/**
* Get all tools from all connected MCP servers.
*/
getTools(): Anthropic.Tool[] {
const allTools: Anthropic.Tool[] = [];
for (const server of this.servers.values()) {
allTools.push(...server.tools);
}
return allTools;
}
/**
* Call a tool on a connected MCP server.
* Tool name format: mcp__{serverName}__{toolName}
*/
async callTool(fullToolName: string, args: Record<string, unknown>): Promise<{ success: boolean; output: string }> {
// Parse mcp__{server}__{tool}
const parts = fullToolName.split("__");
if (parts.length < 3 || parts[0] !== "mcp") {
return { success: false, output: `Invalid MCP tool name: ${fullToolName}` };
}
const serverName = parts[1];
const toolName = parts.slice(2).join("__"); // Handle tool names with __ in them
const server = this.servers.get(serverName);
if (!server) {
return { success: false, output: `MCP server "${serverName}" not connected` };
}
try {
const result = await server.client.callTool({ name: toolName, arguments: args });
// Extract text content from the result
const content = result.content;
if (Array.isArray(content)) {
const texts = content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text);
return { success: !result.isError, output: texts.join("\n") || JSON.stringify(content) };
}
return { success: !result.isError, output: String(content) };
} catch (err: any) {
return { success: false, output: `MCP tool error: ${err.message || err}` };
}
}
/**
* Check if a tool name is an MCP tool.
*/
isMcpTool(name: string): boolean {
return name.startsWith("mcp__");
}
/**
* Get status info about connected servers.
*/
getStatus(): { name: string; toolCount: number }[] {
return Array.from(this.servers.values()).map(s => ({
name: s.name,
toolCount: s.tools.length,
}));
}
/**
* Disconnect all servers gracefully.
*/
async disconnectAll(): Promise<void> {
const closePromises: Promise<void>[] = [];
for (const server of this.servers.values()) {
closePromises.push(
server.client.close().catch(() => {})
);
}
await Promise.allSettled(closePromises);
this.servers.clear();
this.connectPromise = null;
}
}
// Singleton instance
export const mcpClientManager = new McpClientManager();