summaryrefslogtreecommitdiffhomepage
path: root/packages/mcp/src/manager.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/mcp/src/manager.ts')
-rw-r--r--packages/mcp/src/manager.ts367
1 files changed, 194 insertions, 173 deletions
diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts
index a9c06de..1d719c6 100644
--- a/packages/mcp/src/manager.ts
+++ b/packages/mcp/src/manager.ts
@@ -12,194 +12,215 @@ import type { Connection, SpawnProcess } from "./transport.js";
import type { McpServerState, McpServerStatus, ResolvedMcpServer } from "./types.js";
export interface Logger {
- readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
- readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
- readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
+ readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void;
+ readonly error: (msg: string, attrs?: Record<string, unknown>) => void;
}
export interface McpManagerDeps {
- readonly spawn: SpawnProcess;
- readonly logger?: Logger;
- readonly now?: () => number;
+ readonly spawn: SpawnProcess;
+ readonly logger?: Logger;
+ readonly now?: () => number;
}
export type ConnectionFactory = (
- server: ResolvedMcpServer,
- cwd: string,
+ server: ResolvedMcpServer,
+ cwd: string,
) => { connection: Connection; promise: Promise<void> };
type ClientEntry = {
- readonly client: McpClient;
- readonly server: ResolvedMcpServer;
- readonly promise: Promise<void>;
+ readonly client: McpClient;
+ readonly server: ResolvedMcpServer;
+ readonly promise: Promise<void>;
};
type BrokenEntry = {
- readonly brokenAt: number;
- readonly error: string;
+ readonly brokenAt: number;
+ readonly error: string;
};
const BACKOFF_MS = 30_000;
export class McpManager {
- private clients = new Map<string, ClientEntry>();
- private broken = new Map<string, BrokenEntry>();
- private spawning = new Map<string, Promise<void>>();
- private readonly deps: McpManagerDeps;
- private readonly connectionFactory: ConnectionFactory;
- private readonly now: () => number;
-
- constructor(deps: McpManagerDeps, connectionFactory: ConnectionFactory) {
- this.deps = deps;
- this.connectionFactory = connectionFactory;
- this.now = deps.now ?? Date.now;
- }
-
- getClient(serverId: string): McpClient | undefined {
- return this.clients.get(serverId)?.client;
- }
-
- getServerState(serverId: string): McpServerState {
- const brokenEntry = this.broken.get(serverId);
- if (brokenEntry) {
- const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
- if (backoffElapsed) {
- this.broken.delete(serverId);
- } else {
- return "error";
- }
- }
-
- const entry = this.clients.get(serverId);
- if (!entry) return "disconnected";
-
- const state = entry.client.getState();
- if (state === "error") return "error";
- if (state === "connecting") return "connecting";
- if (state === "connected") return "connected";
- return "disconnected";
- }
-
- status(servers: readonly ResolvedMcpServer[]): McpServerStatus[] {
- const results: McpServerStatus[] = [];
-
- for (const server of servers) {
- const state = this.getServerState(server.id);
- const entry = this.clients.get(server.id);
- const brokenEntry = this.broken.get(server.id);
-
- const status: McpServerStatus = {
- id: server.id,
- state,
- toolCount: entry?.client.getTools().length ?? 0,
- };
- if (state === "error" && brokenEntry) {
- (status as { error?: string }).error = brokenEntry.error;
- } else if (state === "error" && entry?.client.getState() === "error") {
- (status as { error?: string }).error = brokenEntry?.error ?? `${server.id}: client error`;
- }
- results.push(status);
- }
-
- return results;
- }
-
- async ensureConnected(server: ResolvedMcpServer, cwd: string): Promise<McpClient> {
- const existing = this.clients.get(server.id);
- if (existing && existing.client.getState() === "connected") {
- return existing.client;
- }
-
- const brokenEntry = this.broken.get(server.id);
- if (brokenEntry) {
- const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
- if (!backoffElapsed) {
- throw new Error(brokenEntry.error);
- }
- this.broken.delete(server.id);
- }
-
- await this.spawnClient(server, cwd);
- const entry = this.clients.get(server.id);
- if (!entry) {
- throw new Error(`Failed to spawn MCP client for ${server.id}`);
- }
- if (entry.client.getState() === "error") {
- const brokenNow = this.broken.get(server.id);
- throw new Error(brokenNow?.error ?? `${server.id}: client error`);
- }
- return entry.client;
- }
-
- private async spawnClient(server: ResolvedMcpServer, cwd: string): Promise<void> {
- const existingSpawn = this.spawning.get(server.id);
- if (existingSpawn) return existingSpawn;
-
- const spawnPromise = this.doSpawn(server, cwd);
- this.spawning.set(server.id, spawnPromise);
-
- try {
- await spawnPromise;
- } finally {
- this.spawning.delete(server.id);
- }
- }
-
- private async doSpawn(server: ResolvedMcpServer, cwd: string): Promise<void> {
- const { connection, promise } = this.connectionFactory(server, cwd);
-
- const client = new McpClient({ connection });
-
- const entry: ClientEntry = {
- client,
- server,
- promise: this.initClient(client, server, promise),
- };
-
- this.clients.set(server.id, entry);
- await entry.promise;
-
- // If initialization failed, the client is in an error state and broken[]
- // is already populated. Drop the half-created client (and reap its child
- // process) so a later retry spawns fresh instead of returning a dead entry.
- if (client.getState() === "error") {
- this.clients.delete(server.id);
- client.close();
- }
- }
-
- private async initClient(
- client: McpClient,
- server: ResolvedMcpServer,
- _transportPromise: Promise<void>,
- ): Promise<void> {
- try {
- await client.initialize();
- await client.listTools();
- this.deps.logger?.info("MCP server connected", {
- serverId: server.id,
- toolCount: String(client.getTools().length),
- });
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : String(err);
- this.broken.set(server.id, {
- brokenAt: this.now(),
- error: `${server.id}: ${message}`,
- });
- this.deps.logger?.warn("MCP server failed to connect", {
- serverId: server.id,
- error: message,
- });
- }
- }
-
- shutdownAll(): void {
- for (const [, entry] of this.clients) {
- entry.client.close();
- }
- this.clients.clear();
- this.broken.clear();
- this.spawning.clear();
- this.deps.logger?.info("All MCP servers shut down");
- }
+ private clients = new Map<string, ClientEntry>();
+ private broken = new Map<string, BrokenEntry>();
+ private spawning = new Map<string, Promise<void>>();
+ private readonly deps: McpManagerDeps;
+ private readonly connectionFactory: ConnectionFactory;
+ private readonly now: () => number;
+
+ constructor(deps: McpManagerDeps, connectionFactory: ConnectionFactory) {
+ this.deps = deps;
+ this.connectionFactory = connectionFactory;
+ this.now = deps.now ?? Date.now;
+ }
+
+ getClient(serverId: string): McpClient | undefined {
+ return this.clients.get(serverId)?.client;
+ }
+
+ getServerState(serverId: string): McpServerState {
+ const brokenEntry = this.broken.get(serverId);
+ if (brokenEntry) {
+ const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
+ if (backoffElapsed) {
+ this.broken.delete(serverId);
+ } else {
+ return "error";
+ }
+ }
+
+ const entry = this.clients.get(serverId);
+ if (!entry) return "disconnected";
+
+ const state = entry.client.getState();
+ if (state === "error") return "error";
+ if (state === "connecting") return "connecting";
+ if (state === "connected") return "connected";
+ return "disconnected";
+ }
+
+ status(servers: readonly ResolvedMcpServer[]): McpServerStatus[] {
+ const results: McpServerStatus[] = [];
+
+ for (const server of servers) {
+ const state = this.getServerState(server.id);
+ const entry = this.clients.get(server.id);
+ const brokenEntry = this.broken.get(server.id);
+
+ const status: McpServerStatus = {
+ id: server.id,
+ state,
+ toolCount: entry?.client.getTools().length ?? 0,
+ };
+ if (state === "error" && brokenEntry) {
+ (status as { error?: string }).error = brokenEntry.error;
+ } else if (state === "error" && entry?.client.getState() === "error") {
+ (status as { error?: string }).error = brokenEntry?.error ?? `${server.id}: client error`;
+ }
+ results.push(status);
+ }
+
+ return results;
+ }
+
+ /**
+ * Ensure a client for `server` is connected, lazily spawning + handshaking on
+ * first access. The optional `signal` (the turn's abort signal) is forwarded
+ * into the `initialize`/`listTools` handshake so `POST /conversations/:id/stop`
+ * can interrupt a stuck connect; the operations are independently bounded by
+ * their own default timeout, so a misbehaving server cannot hang a turn even
+ * when no signal is supplied.
+ */
+ async ensureConnected(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<McpClient> {
+ const existing = this.clients.get(server.id);
+ if (existing && existing.client.getState() === "connected") {
+ return existing.client;
+ }
+
+ const brokenEntry = this.broken.get(server.id);
+ if (brokenEntry) {
+ const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS;
+ if (!backoffElapsed) {
+ throw new Error(brokenEntry.error);
+ }
+ this.broken.delete(server.id);
+ }
+
+ await this.spawnClient(server, cwd, signal);
+ const entry = this.clients.get(server.id);
+ if (!entry) {
+ throw new Error(`Failed to spawn MCP client for ${server.id}`);
+ }
+ if (entry.client.getState() === "error") {
+ const brokenNow = this.broken.get(server.id);
+ throw new Error(brokenNow?.error ?? `${server.id}: client error`);
+ }
+ return entry.client;
+ }
+
+ private async spawnClient(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
+ const existingSpawn = this.spawning.get(server.id);
+ if (existingSpawn) return existingSpawn;
+
+ const spawnPromise = this.doSpawn(server, cwd, signal);
+ this.spawning.set(server.id, spawnPromise);
+
+ try {
+ await spawnPromise;
+ } finally {
+ this.spawning.delete(server.id);
+ }
+ }
+
+ private async doSpawn(
+ server: ResolvedMcpServer,
+ cwd: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
+ const { connection, promise } = this.connectionFactory(server, cwd);
+
+ const client = new McpClient({ connection });
+
+ const entry: ClientEntry = {
+ client,
+ server,
+ promise: this.initClient(client, server, promise, signal),
+ };
+
+ this.clients.set(server.id, entry);
+ await entry.promise;
+
+ // If initialization failed, the client is in an error state and broken[]
+ // is already populated. Drop the half-created client (and reap its child
+ // process) so a later retry spawns fresh instead of returning a dead entry.
+ if (client.getState() === "error") {
+ this.clients.delete(server.id);
+ client.close();
+ }
+ }
+
+ private async initClient(
+ client: McpClient,
+ server: ResolvedMcpServer,
+ _transportPromise: Promise<void>,
+ signal?: AbortSignal,
+ ): Promise<void> {
+ try {
+ await client.initialize(signal);
+ await client.listTools(signal);
+ this.deps.logger?.info("MCP server connected", {
+ serverId: server.id,
+ toolCount: String(client.getTools().length),
+ });
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.broken.set(server.id, {
+ brokenAt: this.now(),
+ error: `${server.id}: ${message}`,
+ });
+ this.deps.logger?.warn("MCP server failed to connect", {
+ serverId: server.id,
+ error: message,
+ });
+ }
+ }
+
+ shutdownAll(): void {
+ for (const [, entry] of this.clients) {
+ entry.client.close();
+ }
+ this.clients.clear();
+ this.broken.clear();
+ this.spawning.clear();
+ this.deps.logger?.info("All MCP servers shut down");
+ }
}