/** * MCP extension — manifest + activate(host). * * Builds the manager with real adapters, registers MCP tools via host.defineTool * (on connect + on list_changed re-list), registers a toolsFilter that drops * tools from disconnected/errored servers, and provides the mcpServiceHandle. * * The production `extension` wires real Bun adapters. `makeMcpExtension(deps)` * accepts injectable spawn/readFile/getCwd so the lifecycle is testable against * in-memory backends (mirrors the LSP extension's injected adapters). */ import type { Extension, HostAPI, ServiceHandle } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import { type ToolAssembly, toolsFilter } from "@dispatch/session-orchestrator"; import type { McpClient } from "./client.js"; import { resolveServers } from "./config.js"; import type { Logger } from "./manager.js"; import { McpManager } from "./manager.js"; import { adaptTool, namespace } from "./registry.js"; import type { SpawnedProcess, SpawnProcess } from "./transport.js"; import { createStdioTransport } from "./transport.js"; import type { McpServerStatus, McpService, ResolvedMcpServer } from "./types.js"; export const mcpServiceHandle: ServiceHandle = defineService("mcp"); /** Filesystem + process adapters injected into the extension for testability. */ export interface McpExtensionDeps { readonly spawn: SpawnProcess; readonly readFile: (path: string) => Promise; readonly getCwd: () => string; } /** * Pure tool-filter logic: remove MCP tools whose owning server is not * currently connected. Non-MCP tools (no entry in `toolToServer`) are kept. * Extracted from the filter handler so it is unit-testable without I/O. */ export function filterMcpTools( assembly: ToolAssembly, toolToServer: ReadonlyMap, connectedServerIds: ReadonlySet, ): ToolAssembly { const filtered = assembly.tools.filter((tool) => { const serverId = toolToServer.get(tool.name); if (serverId === undefined) return true; return connectedServerIds.has(serverId); }); return { tools: filtered, ...(assembly.cwd !== undefined && { cwd: assembly.cwd }), ...(assembly.computerId !== undefined && { computerId: assembly.computerId }), conversationId: assembly.conversationId, }; } /** Map a host Logger to the manager's narrower Logger surface. */ function wrapLogger(logger: HostAPI["logger"]): Logger { return { info: (msg, attrs) => logger.info(msg, attrs), warn: (msg, attrs) => logger.warn(msg, attrs), error: (msg, attrs) => logger.error(msg, attrs), }; } export function makeMcpExtension(deps: McpExtensionDeps): Extension { // Module-scoped store so deactivate can reach the manager. Lives in the // factory closure so each built extension has its own. const store: { manager: McpManager | null } = { manager: null }; return { manifest: { id: "mcp", name: "Model Context Protocol", version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", activation: "eager", dependsOn: ["session-orchestrator"], capabilities: { spawn: true }, contributes: { tools: [], services: ["mcp"] }, }, activate(host: HostAPI) { const logger = host.logger; const connectionFactory = (server: ResolvedMcpServer, cwd: string) => { return createStdioTransport( { spawn: deps.spawn, command: server.command, ...(server.env !== undefined && { env: server.env }), }, cwd, ); }; const manager = new McpManager( { spawn: deps.spawn, logger: wrapLogger(logger) }, connectionFactory, ); // Track which tool names belong to which server for the filter. const toolToServer = new Map(); function registerToolsFromClient(serverId: string, client: McpClient): void { const tools = client.getTools(); for (const mcpTool of tools) { const name = namespace(serverId, mcpTool.name); toolToServer.set(name, serverId); host.defineTool(adaptTool(serverId, mcpTool, client)); } } async function connectAndRegister(server: ResolvedMcpServer, cwd: string): Promise { const client = await manager.ensureConnected(server, cwd); registerToolsFromClient(server.id, client); // Wire list_changed → re-list → re-register. onToolsChanged replaces // the handler; ensureConnected returns the same cached client so this // is idempotent across turns. client.onToolsChanged(async () => { try { await client.listTools(); registerToolsFromClient(server.id, client); } catch (err: unknown) { logger.error("MCP tools re-list failed", { serverId: server.id, error: err instanceof Error ? err.message : String(err), }); } }); } // Resolve config + ensure servers connected, then drop tools whose // server is not connected. Lazy-spawn happens here (first turn). host.addFilter(toolsFilter, async (assembly: ToolAssembly): Promise => { const cwd = assembly.cwd ?? deps.getCwd(); const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); for (const server of servers) { try { await connectAndRegister(server, cwd); } catch { // Connection failure — the manager tracks broken state. } } const statuses = manager.status(servers); const connectedIds = new Set( statuses.filter((s) => s.state === "connected").map((s) => s.id), ); return filterMcpTools(assembly, toolToServer, connectedIds); }); // Provide the MCP service (status introspection). const service: McpService = { async status(cwd: string): Promise { const dispatchMcpJson = await deps.readFile(joinPath(cwd, ".dispatch", "mcp.json")); const opencodeJson = await deps.readFile(joinPath(cwd, "opencode.json")); const { servers } = resolveServers({ dispatchMcpJson, opencodeJson }); return manager.status(servers); }, }; host.provideService(mcpServiceHandle, service); store.manager = manager; logger.info("MCP extension activated"); }, deactivate() { store.manager?.shutdownAll(); store.manager = null; }, }; } // --- real Bun-backed adapters (production wiring) --- function realSpawn( command: readonly string[], opts: { readonly cwd: string; readonly env?: Readonly> | undefined }, ): SpawnedProcess { const env: Record = { ...process.env }; if (opts.env) { for (const [key, value] of Object.entries(opts.env)) { env[key] = value; } } const proc = Bun.spawn(command as string[], { cwd: opts.cwd, env: env as Record, stdin: "pipe", stdout: "pipe", stderr: "pipe", }); return { stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr, pid: proc.pid, kill: () => proc.kill(), }; } async function realReadFile(path: string): Promise { try { const file = Bun.file(path); if (await file.exists()) { return file.text(); } return null; } catch { return null; } } function joinPath(...parts: readonly string[]): string { return parts.join("/"); } /** Production extension: real Bun spawn + filesystem reads. */ export const extension: Extension = makeMcpExtension({ spawn: realSpawn, readFile: realReadFile, getCwd: () => process.cwd(), });