summaryrefslogtreecommitdiffhomepage
path: root/packages/mcp/src/extension.ts
blob: e1c4d52a692f0e5c9b3efeef409c533e234ce805 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
 * 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<McpService> = defineService<McpService>("mcp");

/** Filesystem + process adapters injected into the extension for testability. */
export interface McpExtensionDeps {
	readonly spawn: SpawnProcess;
	readonly readFile: (path: string) => Promise<string | null>;
	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<string, string>,
	connectedServerIds: ReadonlySet<string>,
): 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<string, string>();

			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<void> {
				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<ToolAssembly> => {
				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<readonly McpServerStatus[]> {
					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<Record<string, string>> | undefined },
): SpawnedProcess {
	const env: Record<string, string | undefined> = { ...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<string, string>,
		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<string | null> {
	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(),
});