@yofriadi/pi-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { McpResolvedConfig } from "./config/mcp-config";
3
+ import { createMcpManager } from "./runtime/mcp-manager";
4
+ import { registerMcpTools } from "./tools/mcp-tools";
5
+
6
+ export default function mcpExtension(pi: ExtensionAPI): void {
7
+ const manager = createMcpManager();
8
+ const bridge = registerMcpTools(pi, manager);
9
+
10
+ pi.on("session_start", async (_event, ctx) => {
11
+ const state = await manager.startSession({
12
+ cwd: ctx.cwd,
13
+ sessionId: ctx.sessionManager.getSessionId(),
14
+ sessionFile: ctx.sessionManager.getSessionFile(),
15
+ env: process.env,
16
+ });
17
+ notifyConfigDiagnostics(ctx.ui.notify, state.config);
18
+ const bridgeSync = bridge.sync();
19
+ notifyBridgeSync(ctx.ui.notify, bridgeSync);
20
+
21
+ if (state.runtime.state === "error") {
22
+ ctx.ui.notify(`MCP startup finished with errors: ${state.runtime.reason}`, "warning");
23
+ }
24
+ });
25
+
26
+ pi.on("session_switch", async (_event, ctx) => {
27
+ manager.setSessionContext({
28
+ cwd: ctx.cwd,
29
+ sessionId: ctx.sessionManager.getSessionId(),
30
+ sessionFile: ctx.sessionManager.getSessionFile(),
31
+ });
32
+ });
33
+
34
+ pi.on("session_shutdown", async () => {
35
+ await manager.stopSession();
36
+ });
37
+
38
+ pi.registerCommand("mcp-reload", {
39
+ description: "Reload MCP config files and restart MCP runtime",
40
+ handler: async (_args, ctx) => {
41
+ const state = await manager.reloadSession({
42
+ cwd: ctx.cwd,
43
+ sessionId: ctx.sessionManager.getSessionId(),
44
+ sessionFile: ctx.sessionManager.getSessionFile(),
45
+ env: process.env,
46
+ });
47
+ const bridgeSync = bridge.sync();
48
+
49
+ notifyConfigDiagnostics(ctx.ui.notify, state.config);
50
+ notifyBridgeSync(ctx.ui.notify, bridgeSync);
51
+ if (state.runtime.state === "error") {
52
+ ctx.ui.notify(`MCP runtime reloaded with errors: ${state.runtime.reason}`, "warning");
53
+ return;
54
+ }
55
+ ctx.ui.notify("MCP runtime reloaded from config files.", "info");
56
+ },
57
+ });
58
+ }
59
+
60
+ function notifyConfigDiagnostics(
61
+ notify: (message: string, type?: "info" | "warning" | "error") => void,
62
+ config: McpResolvedConfig,
63
+ ): void {
64
+ if (config.diagnostics.length === 0) {
65
+ return;
66
+ }
67
+ const warningCount = config.diagnostics.filter((diag) => diag.level === "warning").length;
68
+ const errorCount = config.diagnostics.filter((diag) => diag.level === "error").length;
69
+ notify(
70
+ `MCP config diagnostics: ${errorCount} error(s), ${warningCount} warning(s). Run /mcp-status for details.`,
71
+ errorCount > 0 ? "warning" : "info",
72
+ );
73
+ }
74
+
75
+ function notifyBridgeSync(
76
+ notify: (message: string, type?: "info" | "warning" | "error") => void,
77
+ result: {
78
+ added: number;
79
+ total: number;
80
+ failed: Array<{ key: string; reason: string }>;
81
+ },
82
+ ): void {
83
+ if (result.added > 0) {
84
+ notify(`MCP tool bridge registered ${result.added} new tool(s), total ${result.total}.`, "info");
85
+ }
86
+ if (result.failed.length > 0) {
87
+ const first = result.failed[0];
88
+ notify(
89
+ `MCP tool bridge failed to register ${result.failed.length} tool(s). First failure: ${first.key} -> ${first.reason}`,
90
+ "warning",
91
+ );
92
+ }
93
+ }
@@ -0,0 +1,419 @@
1
+ import { createMcpConfigResolver, type McpResolvedConfig } from "../config/mcp-config";
2
+ import { createMcpRuntime, type McpRequestOptions, type McpRuntime, type McpRuntimeStatus } from "./mcp-runtime";
3
+
4
+ export type McpManagerLifecycleState = "inactive" | "starting" | "ready" | "stopping" | "error";
5
+
6
+ export interface McpManagerSessionContext {
7
+ cwd: string;
8
+ sessionId?: string;
9
+ sessionFile?: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ explicitConfigPath?: string;
12
+ }
13
+
14
+ export interface McpManagerSessionState {
15
+ cwd: string;
16
+ sessionId?: string;
17
+ sessionFile?: string;
18
+ startedAt: string;
19
+ lastReloadAt?: string;
20
+ stoppedAt?: string;
21
+ reloadCount: number;
22
+ isActive: boolean;
23
+ }
24
+
25
+ export interface McpManagerToolListState {
26
+ server: string;
27
+ state: "ready" | "error" | "stale";
28
+ reason: string;
29
+ refreshedAt?: string;
30
+ tools: unknown[];
31
+ }
32
+
33
+ export interface McpManagerState {
34
+ lifecycle: McpManagerLifecycleState;
35
+ reason: string;
36
+ session?: McpManagerSessionState;
37
+ config: McpResolvedConfig;
38
+ runtime: McpRuntimeStatus;
39
+ toolLists: Record<string, McpManagerToolListState>;
40
+ }
41
+
42
+ export interface McpManager {
43
+ startSession(context: McpManagerSessionContext): Promise<McpManagerState>;
44
+ stopSession(): Promise<McpManagerState>;
45
+ reloadSession(context: McpManagerSessionContext): Promise<McpManagerState>;
46
+ refreshToolLists(serverNames?: string[]): Promise<McpManagerState>;
47
+ setSessionContext(context: McpManagerSessionContext): McpManagerState;
48
+ request(serverName: string, method: string, params?: unknown, options?: McpRequestOptions): Promise<unknown>;
49
+ listTools(serverName: string, options?: McpRequestOptions): Promise<unknown>;
50
+ callTool(serverName: string, toolName: string, args?: unknown, options?: McpRequestOptions): Promise<unknown>;
51
+ getState(): McpManagerState;
52
+ }
53
+
54
+ interface McpManagerOptions {
55
+ runtime?: McpRuntime;
56
+ resolveConfig?: (context: McpManagerSessionContext) => McpResolvedConfig;
57
+ now?: () => Date;
58
+ }
59
+
60
+ const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 20_000;
61
+ const EMPTY_CONFIG: McpResolvedConfig = {
62
+ servers: [],
63
+ diagnostics: [],
64
+ sourcePaths: [],
65
+ };
66
+
67
+ export function createMcpManager(options: McpManagerOptions = {}): McpManager {
68
+ const runtime = options.runtime ?? createMcpRuntime();
69
+ const now = options.now ?? (() => new Date());
70
+ const resolveConfig =
71
+ options.resolveConfig ??
72
+ ((context: McpManagerSessionContext) =>
73
+ createMcpConfigResolver({
74
+ cwd: context.cwd,
75
+ env: context.env ?? process.env,
76
+ }).resolve(context.explicitConfigPath));
77
+
78
+ let lifecycle: McpManagerLifecycleState = "inactive";
79
+ let lifecycleReason = "not started";
80
+ let currentConfig: McpResolvedConfig = cloneResolvedConfig(EMPTY_CONFIG);
81
+ let sessionState: McpManagerSessionState | undefined;
82
+ const toolLists = new Map<string, McpManagerToolListState>();
83
+ let lifecycleQueue = Promise.resolve();
84
+
85
+ const manager: McpManager = {
86
+ async startSession(context: McpManagerSessionContext): Promise<McpManagerState> {
87
+ return runSerialized(async () => {
88
+ lifecycle = "starting";
89
+ lifecycleReason = "starting MCP manager";
90
+ upsertSession(context, false);
91
+
92
+ try {
93
+ const resolved = resolveConfig(context);
94
+ currentConfig = cloneResolvedConfig(resolved);
95
+ await runtime.start(resolved);
96
+ await refreshToolListsInternal();
97
+ syncLifecycleFromRuntime();
98
+ } catch (error) {
99
+ await runtime.stop().catch(() => undefined);
100
+ lifecycle = "error";
101
+ lifecycleReason = `startup failed: ${formatError(error)}`;
102
+ markSessionStopped();
103
+ toolLists.clear();
104
+ }
105
+
106
+ return snapshot();
107
+ });
108
+ },
109
+
110
+ async stopSession(): Promise<McpManagerState> {
111
+ return runSerialized(async () => {
112
+ lifecycle = "stopping";
113
+ lifecycleReason = "stopping MCP manager";
114
+
115
+ let stopError: string | undefined;
116
+ try {
117
+ await runtime.stop();
118
+ } catch (error) {
119
+ stopError = formatError(error);
120
+ }
121
+
122
+ toolLists.clear();
123
+ markSessionStopped();
124
+ lifecycle = "inactive";
125
+ const runtimeStatus = runtime.getStatus();
126
+ lifecycleReason = stopError
127
+ ? `stopped with error: ${stopError}`
128
+ : (runtimeStatus.reason ?? "MCP manager stopped");
129
+ return snapshot();
130
+ });
131
+ },
132
+
133
+ async reloadSession(context: McpManagerSessionContext): Promise<McpManagerState> {
134
+ return runSerialized(async () => {
135
+ lifecycle = "starting";
136
+ lifecycleReason = "reloading MCP manager";
137
+ upsertSession(context, true);
138
+
139
+ try {
140
+ const resolved = resolveConfig(context);
141
+ currentConfig = cloneResolvedConfig(resolved);
142
+ await runtime.start(resolved);
143
+ await refreshToolListsInternal();
144
+ syncLifecycleFromRuntime();
145
+ } catch (error) {
146
+ await runtime.stop().catch(() => undefined);
147
+ lifecycle = "error";
148
+ lifecycleReason = `reload failed: ${formatError(error)}`;
149
+ markSessionStopped();
150
+ toolLists.clear();
151
+ }
152
+
153
+ return snapshot();
154
+ });
155
+ },
156
+
157
+ async refreshToolLists(serverNames?: string[]): Promise<McpManagerState> {
158
+ return runSerialized(async () => {
159
+ await refreshToolListsInternal(serverNames);
160
+ syncLifecycleFromRuntime();
161
+ return snapshot();
162
+ });
163
+ },
164
+
165
+ setSessionContext(context: McpManagerSessionContext): McpManagerState {
166
+ upsertSession(context, false);
167
+ return snapshot();
168
+ },
169
+
170
+ request(serverName: string, method: string, params: unknown = {}, options?: McpRequestOptions): Promise<unknown> {
171
+ return runtime.request(serverName, method, params, options);
172
+ },
173
+
174
+ async listTools(serverName: string, options?: McpRequestOptions): Promise<unknown> {
175
+ try {
176
+ const response = await runtime.listTools(serverName, options);
177
+ recordToolListSuccess(serverName, response);
178
+ return response;
179
+ } catch (error) {
180
+ recordToolListError(serverName, formatError(error));
181
+ throw error;
182
+ }
183
+ },
184
+
185
+ callTool(
186
+ serverName: string,
187
+ toolName: string,
188
+ args: unknown = {},
189
+ options?: McpRequestOptions,
190
+ ): Promise<unknown> {
191
+ return runtime.callTool(serverName, toolName, args, options);
192
+ },
193
+
194
+ getState(): McpManagerState {
195
+ syncLifecycleFromRuntime();
196
+ return snapshot();
197
+ },
198
+ };
199
+
200
+ return manager;
201
+
202
+ function runSerialized<T>(operation: () => Promise<T>): Promise<T> {
203
+ const run = lifecycleQueue.then(operation, operation);
204
+ lifecycleQueue = run.then(
205
+ () => undefined,
206
+ () => undefined,
207
+ );
208
+ return run;
209
+ }
210
+
211
+ function upsertSession(context: McpManagerSessionContext, isReload: boolean): void {
212
+ const timestamp = now().toISOString();
213
+ const isSameSession =
214
+ !!sessionState &&
215
+ sessionState.sessionId === context.sessionId &&
216
+ sessionState.sessionFile === context.sessionFile &&
217
+ sessionState.cwd === context.cwd;
218
+
219
+ if (!sessionState || !isSameSession) {
220
+ sessionState = {
221
+ cwd: context.cwd,
222
+ sessionId: context.sessionId,
223
+ sessionFile: context.sessionFile,
224
+ startedAt: timestamp,
225
+ lastReloadAt: isReload ? timestamp : undefined,
226
+ stoppedAt: undefined,
227
+ reloadCount: isReload ? 1 : 0,
228
+ isActive: true,
229
+ };
230
+ return;
231
+ }
232
+
233
+ sessionState = {
234
+ ...sessionState,
235
+ cwd: context.cwd,
236
+ sessionId: context.sessionId,
237
+ sessionFile: context.sessionFile,
238
+ isActive: true,
239
+ stoppedAt: undefined,
240
+ reloadCount: isReload ? sessionState.reloadCount + 1 : sessionState.reloadCount,
241
+ lastReloadAt: isReload ? timestamp : sessionState.lastReloadAt,
242
+ };
243
+ }
244
+
245
+ function markSessionStopped(): void {
246
+ if (!sessionState) {
247
+ return;
248
+ }
249
+ sessionState = {
250
+ ...sessionState,
251
+ isActive: false,
252
+ stoppedAt: now().toISOString(),
253
+ };
254
+ }
255
+
256
+ async function refreshToolListsInternal(serverNames?: string[]): Promise<void> {
257
+ const runtimeStatus = runtime.getStatus();
258
+ const configuredServerNames = currentConfig.servers.map((server) => server.name);
259
+ const targetServerNames = dedupeNames(serverNames ?? configuredServerNames);
260
+
261
+ if (!serverNames) {
262
+ const configured = new Set(configuredServerNames);
263
+ for (const existing of toolLists.keys()) {
264
+ if (!configured.has(existing)) {
265
+ toolLists.delete(existing);
266
+ }
267
+ }
268
+ }
269
+
270
+ const statusesByServer = new Map(runtimeStatus.servers.map((server) => [server.name, server]));
271
+ for (const serverName of targetServerNames) {
272
+ const runtimeServerStatus = statusesByServer.get(serverName);
273
+ if (!runtimeServerStatus || runtimeServerStatus.state !== "ready") {
274
+ const reason = runtimeServerStatus
275
+ ? `server is ${runtimeServerStatus.state}: ${runtimeServerStatus.reason}`
276
+ : "server is not running";
277
+ recordToolListStale(serverName, reason);
278
+ continue;
279
+ }
280
+
281
+ try {
282
+ const response = await runtime.listTools(serverName, { timeoutMs: DEFAULT_TOOL_REFRESH_TIMEOUT_MS });
283
+ recordToolListSuccess(serverName, response);
284
+ } catch (error) {
285
+ recordToolListError(serverName, formatError(error));
286
+ }
287
+ }
288
+ }
289
+
290
+ function recordToolListSuccess(serverName: string, response: unknown): void {
291
+ const extractedTools = extractTools(response);
292
+ toolLists.set(serverName, {
293
+ server: serverName,
294
+ state: "ready",
295
+ reason: `refreshed ${extractedTools.length} tool(s)`,
296
+ refreshedAt: now().toISOString(),
297
+ tools: extractedTools,
298
+ });
299
+ }
300
+
301
+ function recordToolListError(serverName: string, error: string): void {
302
+ const existing = toolLists.get(serverName);
303
+ toolLists.set(serverName, {
304
+ server: serverName,
305
+ state: "error",
306
+ reason: error,
307
+ refreshedAt: now().toISOString(),
308
+ tools: existing?.tools ?? [],
309
+ });
310
+ }
311
+
312
+ function recordToolListStale(serverName: string, reason: string): void {
313
+ const existing = toolLists.get(serverName);
314
+ toolLists.set(serverName, {
315
+ server: serverName,
316
+ state: "stale",
317
+ reason,
318
+ refreshedAt: existing?.refreshedAt,
319
+ tools: existing?.tools ?? [],
320
+ });
321
+ }
322
+
323
+ function syncLifecycleFromRuntime(): void {
324
+ const runtimeStatus = runtime.getStatus();
325
+ switch (runtimeStatus.state) {
326
+ case "ready":
327
+ lifecycle = "ready";
328
+ lifecycleReason = runtimeStatus.reason;
329
+ if (sessionState) {
330
+ sessionState = {
331
+ ...sessionState,
332
+ isActive: true,
333
+ stoppedAt: undefined,
334
+ };
335
+ }
336
+ return;
337
+ case "error":
338
+ lifecycle = "error";
339
+ lifecycleReason = runtimeStatus.reason;
340
+ return;
341
+ case "inactive":
342
+ if (lifecycle !== "inactive") {
343
+ lifecycle = "inactive";
344
+ }
345
+ if (!lifecycleReason || lifecycleReason === "not started") {
346
+ lifecycleReason = runtimeStatus.reason;
347
+ }
348
+ return;
349
+ case "starting":
350
+ lifecycle = "starting";
351
+ lifecycleReason = runtimeStatus.reason;
352
+ }
353
+ }
354
+
355
+ function snapshot(): McpManagerState {
356
+ return {
357
+ lifecycle,
358
+ reason: lifecycleReason,
359
+ session: sessionState ? { ...sessionState } : undefined,
360
+ config: cloneResolvedConfig(currentConfig),
361
+ runtime: cloneRuntimeStatus(runtime.getStatus()),
362
+ toolLists: Object.fromEntries([...toolLists.entries()].map(([name, state]) => [name, { ...state }])),
363
+ };
364
+ }
365
+ }
366
+
367
+ function extractTools(response: unknown): unknown[] {
368
+ if (!isObject(response) || !Array.isArray(response.tools)) {
369
+ return [];
370
+ }
371
+ return [...response.tools];
372
+ }
373
+
374
+ function cloneResolvedConfig(config: McpResolvedConfig): McpResolvedConfig {
375
+ return {
376
+ servers: config.servers.map((server) => ({
377
+ ...server,
378
+ args: [...server.args],
379
+ headers: { ...server.headers },
380
+ env: { ...server.env },
381
+ })),
382
+ diagnostics: config.diagnostics.map((diagnostic) => ({ ...diagnostic })),
383
+ sourcePaths: [...config.sourcePaths],
384
+ };
385
+ }
386
+
387
+ function cloneRuntimeStatus(status: McpRuntimeStatus): McpRuntimeStatus {
388
+ return {
389
+ ...status,
390
+ servers: status.servers.map((server) => ({
391
+ ...server,
392
+ command: server.command ? [...server.command] : undefined,
393
+ })),
394
+ diagnostics: [...status.diagnostics],
395
+ };
396
+ }
397
+
398
+ function dedupeNames(names: string[]): string[] {
399
+ const unique = new Set<string>();
400
+ for (const name of names) {
401
+ const normalized = name.trim();
402
+ if (!normalized) {
403
+ continue;
404
+ }
405
+ unique.add(normalized);
406
+ }
407
+ return [...unique];
408
+ }
409
+
410
+ function isObject(value: unknown): value is Record<string, unknown> {
411
+ return !!value && typeof value === "object";
412
+ }
413
+
414
+ function formatError(error: unknown): string {
415
+ if (error instanceof Error) {
416
+ return error.message;
417
+ }
418
+ return String(error);
419
+ }