@ryan_nookpi/pi-extension-claude-mcp-bridge 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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/index.ts +1562 -0
  3. package/package.json +39 -0
package/index.ts ADDED
@@ -0,0 +1,1562 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
5
+ import type { TUI } from "@mariozechner/pi-tui";
6
+ import { matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
9
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
10
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
+ import { Type } from "@sinclair/typebox";
12
+
13
+ type RawMcpServer = {
14
+ type?: string;
15
+ enabled?: boolean;
16
+ command?: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ cwd?: string;
20
+ url?: string;
21
+ headers?: Record<string, string>;
22
+ };
23
+
24
+ type NormalizedMcpServer =
25
+ | {
26
+ name: string;
27
+ type: "stdio";
28
+ enabled: boolean;
29
+ command: string;
30
+ args: string[];
31
+ env: Record<string, string>;
32
+ cwd?: string;
33
+ }
34
+ | {
35
+ name: string;
36
+ type: "sse" | "http";
37
+ enabled: boolean;
38
+ url: string;
39
+ headers: Record<string, string>;
40
+ };
41
+
42
+ type DiscoveredTool = {
43
+ name: string;
44
+ description?: string;
45
+ inputSchema: Record<string, unknown>;
46
+ };
47
+
48
+ type ServerStatus = "connecting" | "connected" | "disconnected" | "error";
49
+
50
+ class McpConnection {
51
+ private static readonly MAX_RECONNECT_ATTEMPTS = 5;
52
+ private static readonly INITIAL_RECONNECT_DELAY_MS = 2_000;
53
+ private static readonly MAX_RECONNECT_DELAY_MS = 30_000;
54
+
55
+ private client: Client | null = null;
56
+ private transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null = null;
57
+
58
+ /** When true, suppress onclose/onerror side-effects (e.g. during intentional disconnect or cleanup). */
59
+ private intentionalDisconnect = false;
60
+ private reconnectAttempts = 0;
61
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
62
+ /** Deduplicates concurrent connect() calls. */
63
+ private connectingPromise: Promise<void> | null = null;
64
+
65
+ public status: ServerStatus = "disconnected";
66
+ public error?: string;
67
+ public tools: DiscoveredTool[] = [];
68
+
69
+ constructor(public readonly server: NormalizedMcpServer) {}
70
+
71
+ // ── public API ────────────────────────────────────────────
72
+
73
+ async connect(): Promise<void> {
74
+ // Deduplicate concurrent connect() invocations.
75
+ if (this.connectingPromise) return this.connectingPromise;
76
+ this.connectingPromise = this._doConnect();
77
+ try {
78
+ await this.connectingPromise;
79
+ } finally {
80
+ this.connectingPromise = null;
81
+ }
82
+ }
83
+
84
+ async disconnect(): Promise<void> {
85
+ this.intentionalDisconnect = true;
86
+ this.clearReconnectTimer();
87
+ await this.cleanupConnection();
88
+
89
+ if (this.status !== "error") {
90
+ this.status = "disconnected";
91
+ this.error = undefined;
92
+ }
93
+ this.tools = [];
94
+ }
95
+
96
+ async refreshTools(): Promise<void> {
97
+ if (!this.client) return;
98
+ try {
99
+ const result = await this.client.listTools();
100
+ this.tools = result.tools.map((tool) => ({
101
+ name: tool.name,
102
+ description: tool.description,
103
+ inputSchema: (tool.inputSchema ?? {}) as Record<string, unknown>,
104
+ }));
105
+ } catch {
106
+ this.tools = [];
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Ensure the connection is alive before calling a tool.
112
+ * If disconnected / errored, attempt a fresh reconnect.
113
+ */
114
+ async ensureConnected(): Promise<void> {
115
+ if (this.status === "connected" && this.client) return;
116
+
117
+ // If a connect() is already in flight, piggy-back on it.
118
+ if (this.connectingPromise) {
119
+ await this.connectingPromise;
120
+ if (this.status === "connected" && this.client) return;
121
+ }
122
+
123
+ // Cancel any pending scheduled reconnect and try immediately.
124
+ this.clearReconnectTimer();
125
+ this.reconnectAttempts = 0;
126
+ await this.connect();
127
+ }
128
+
129
+ async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
130
+ if (!this.client || this.status !== "connected") {
131
+ await this.ensureConnected();
132
+ }
133
+ if (!this.client || this.status !== "connected") {
134
+ throw new Error(`MCP server '${this.server.name}' is not connected (status: ${this.status})`);
135
+ }
136
+ return this.client.callTool({ name: toolName, arguments: args });
137
+ }
138
+
139
+ // ── internals ─────────────────────────────────────────────
140
+
141
+ private async _doConnect(): Promise<void> {
142
+ this.clearReconnectTimer();
143
+
144
+ // Guard: prevent the onclose handler of the *old* client from firing
145
+ // a spurious reconnect while we tear it down.
146
+ this.intentionalDisconnect = true;
147
+ await this.cleanupConnection();
148
+ this.intentionalDisconnect = false;
149
+
150
+ this.status = "connecting";
151
+ this.error = undefined;
152
+ this.tools = [];
153
+
154
+ try {
155
+ this.client = new Client({ name: "pi-claude-mcp-bridge", version: "0.1.0" }, { capabilities: {} });
156
+
157
+ if (this.server.type === "stdio") {
158
+ this.transport = new StdioClientTransport({
159
+ command: this.server.command,
160
+ args: this.server.args,
161
+ env: this.server.env,
162
+ cwd: this.server.cwd,
163
+ // Suppress noisy MCP server bootstrap logs on stderr by default.
164
+ // Set PI_MCP_STDERR=inherit when debugging MCP connection issues.
165
+ stderr: process.env.PI_MCP_STDERR === "inherit" ? "inherit" : "ignore",
166
+ });
167
+ } else if (this.server.type === "sse") {
168
+ const sseHeaders = this.server.headers;
169
+ this.transport = new SSEClientTransport(new URL(this.server.url), {
170
+ // EventSourceInit does not support a headers property directly.
171
+ // Inject custom headers into the SSE stream via a fetch wrapper.
172
+ eventSourceInit:
173
+ Object.keys(sseHeaders).length > 0
174
+ ? {
175
+ fetch: (url, init) =>
176
+ fetch(url, {
177
+ ...init,
178
+ headers: { ...init.headers, ...sseHeaders },
179
+ }),
180
+ }
181
+ : undefined,
182
+ requestInit: { headers: sseHeaders },
183
+ });
184
+ } else {
185
+ this.transport = new StreamableHTTPClientTransport(new URL(this.server.url), {
186
+ requestInit: { headers: this.server.headers },
187
+ });
188
+ }
189
+
190
+ await this.client.connect(this.transport);
191
+
192
+ // ── Detect unexpected disconnection & auto-reconnect ──
193
+ this.client.onclose = () => {
194
+ if (this.intentionalDisconnect) return;
195
+ this.status = "disconnected";
196
+ this.client = null;
197
+ this.transport = null;
198
+ this.scheduleReconnect();
199
+ };
200
+
201
+ this.client.onerror = (error: Error) => {
202
+ if (this.intentionalDisconnect) return;
203
+ const msg = error instanceof Error ? error.message : String(error);
204
+ if (msg.includes("unknown message ID")) return; // harmless race; ignore
205
+ this.error = msg;
206
+ };
207
+
208
+ await this.refreshTools();
209
+ this.status = "connected";
210
+ this.reconnectAttempts = 0;
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ // Clean up the half-initialised connection (guard the onclose handler).
214
+ this.intentionalDisconnect = true;
215
+ await this.cleanupConnection();
216
+ this.intentionalDisconnect = false;
217
+
218
+ this.status = "error";
219
+ this.error = message;
220
+ }
221
+ }
222
+
223
+ private async cleanupConnection(): Promise<void> {
224
+ if (this.client) {
225
+ try {
226
+ await this.client.close();
227
+ } catch {
228
+ /* ignore */
229
+ }
230
+ this.client = null;
231
+ }
232
+ if (this.transport) {
233
+ try {
234
+ await this.transport.close();
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ this.transport = null;
239
+ }
240
+ }
241
+
242
+ private clearReconnectTimer(): void {
243
+ if (this.reconnectTimer) {
244
+ clearTimeout(this.reconnectTimer);
245
+ this.reconnectTimer = null;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Schedule a reconnection attempt with exponential back-off.
251
+ * Called automatically when an unexpected transport close is detected.
252
+ */
253
+ private scheduleReconnect(): void {
254
+ this.clearReconnectTimer();
255
+
256
+ if (this.reconnectAttempts >= McpConnection.MAX_RECONNECT_ATTEMPTS) {
257
+ this.status = "error";
258
+ this.error = `Reconnection failed after ${McpConnection.MAX_RECONNECT_ATTEMPTS} attempts for '${this.server.name}'`;
259
+ return;
260
+ }
261
+
262
+ const delay = Math.min(
263
+ McpConnection.INITIAL_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempts,
264
+ McpConnection.MAX_RECONNECT_DELAY_MS,
265
+ );
266
+ this.reconnectAttempts++;
267
+ this.status = "connecting";
268
+
269
+ this.reconnectTimer = setTimeout(async () => {
270
+ await this.connect();
271
+ // If still not connected (connect() swallows its own errors), keep trying.
272
+ if (this.status !== "connected" && !this.intentionalDisconnect) {
273
+ this.scheduleReconnect();
274
+ }
275
+ }, delay);
276
+ }
277
+ }
278
+
279
+ class McpManager {
280
+ private connections = new Map<string, McpConnection>();
281
+ public sourcePath: string | null = null;
282
+
283
+ async replaceServers(servers: NormalizedMcpServer[], sourcePath: string | null): Promise<void> {
284
+ await this.disconnectAll();
285
+ this.connections.clear();
286
+ for (const server of servers) {
287
+ this.connections.set(server.name, new McpConnection(server));
288
+ }
289
+ this.sourcePath = sourcePath;
290
+ }
291
+
292
+ async connectAll(): Promise<void> {
293
+ for (const conn of this.connections.values()) {
294
+ if (!conn.server.enabled) continue;
295
+ await conn.connect();
296
+ }
297
+ }
298
+
299
+ async disconnectAll(): Promise<void> {
300
+ for (const conn of this.connections.values()) {
301
+ await conn.disconnect();
302
+ }
303
+ }
304
+
305
+ getStates(): Array<{
306
+ name: string;
307
+ status: ServerStatus;
308
+ type: NormalizedMcpServer["type"];
309
+ toolCount: number;
310
+ error?: string;
311
+ }> {
312
+ return Array.from(this.connections.values()).map((conn) => ({
313
+ name: conn.server.name,
314
+ status: conn.status,
315
+ type: conn.server.type,
316
+ toolCount: conn.tools.length,
317
+ error: conn.error,
318
+ }));
319
+ }
320
+
321
+ getAllTools(): Array<{ serverName: string; tool: DiscoveredTool }> {
322
+ const tools: Array<{ serverName: string; tool: DiscoveredTool }> = [];
323
+ for (const conn of this.connections.values()) {
324
+ if (conn.status !== "connected") continue;
325
+ for (const tool of conn.tools) {
326
+ tools.push({ serverName: conn.server.name, tool });
327
+ }
328
+ }
329
+ return tools;
330
+ }
331
+
332
+ async callTool(serverName: string, toolName: string, args: Record<string, unknown>): Promise<unknown> {
333
+ const conn = this.connections.get(serverName);
334
+ if (!conn) {
335
+ throw new Error(`MCP server '${serverName}' not found`);
336
+ }
337
+ return conn.callTool(toolName, args);
338
+ }
339
+
340
+ async reconnectServer(name: string): Promise<void> {
341
+ const conn = this.connections.get(name);
342
+ if (!conn) return;
343
+ await conn.disconnect();
344
+ await conn.connect();
345
+ }
346
+
347
+ getServerTools(name: string): DiscoveredTool[] {
348
+ const conn = this.connections.get(name);
349
+ if (!conn) return [];
350
+ return [...conn.tools];
351
+ }
352
+ }
353
+
354
+ type LoadedConfig = {
355
+ sourcePath: string | null;
356
+ servers: NormalizedMcpServer[];
357
+ warnings: string[];
358
+ };
359
+
360
+ type ToolVisibilitySettingsFile = {
361
+ disabledTools?: Record<string, string[]> | string[];
362
+ };
363
+
364
+ type LoadedToolVisibilitySettings = {
365
+ disabledToolKeys: Set<string>;
366
+ warning?: string;
367
+ };
368
+
369
+ const TOOL_VISIBILITY_SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "claude-mcp-bridge-tools.json");
370
+ export const TOOL_VISIBILITY_KEY_SEPARATOR = "\u001f";
371
+
372
+ export function buildToolVisibilityKey(serverName: string, toolName: string): string {
373
+ return `${serverName}${TOOL_VISIBILITY_KEY_SEPARATOR}${toolName}`;
374
+ }
375
+
376
+ export function parseToolVisibilityKey(key: string): { serverName: string; toolName: string } | null {
377
+ const separatorIndex = key.indexOf(TOOL_VISIBILITY_KEY_SEPARATOR);
378
+ if (separatorIndex <= 0 || separatorIndex >= key.length - 1) return null;
379
+
380
+ const serverName = key.slice(0, separatorIndex).trim();
381
+ const toolName = key.slice(separatorIndex + TOOL_VISIBILITY_KEY_SEPARATOR.length).trim();
382
+ if (!serverName || !toolName) return null;
383
+
384
+ return { serverName, toolName };
385
+ }
386
+
387
+ function addDisabledToolKey(result: Set<string>, serverNameRaw: string, toolNameRaw: string): void {
388
+ const serverName = serverNameRaw.trim();
389
+ const toolName = toolNameRaw.trim();
390
+ if (!serverName || !toolName) return;
391
+ result.add(buildToolVisibilityKey(serverName, toolName));
392
+ }
393
+
394
+ function parseDisabledToolList(value: string[]): Set<string> {
395
+ const result = new Set<string>();
396
+ for (const item of value) {
397
+ if (typeof item !== "string") continue;
398
+ const separatorIndex = item.indexOf("/");
399
+ if (separatorIndex <= 0 || separatorIndex >= item.length - 1) continue;
400
+ addDisabledToolKey(result, item.slice(0, separatorIndex), item.slice(separatorIndex + 1));
401
+ }
402
+ return result;
403
+ }
404
+
405
+ function parseDisabledToolMap(value: Record<string, unknown>): Set<string> {
406
+ const result = new Set<string>();
407
+ for (const [serverNameRaw, tools] of Object.entries(value)) {
408
+ if (!Array.isArray(tools)) continue;
409
+ for (const toolNameRaw of tools) {
410
+ if (typeof toolNameRaw !== "string") continue;
411
+ addDisabledToolKey(result, serverNameRaw, toolNameRaw);
412
+ }
413
+ }
414
+ return result;
415
+ }
416
+
417
+ export function parseDisabledToolKeys(value: unknown): Set<string> {
418
+ if (!value) return new Set<string>();
419
+ if (Array.isArray(value)) return parseDisabledToolList(value);
420
+ if (typeof value !== "object") return new Set<string>();
421
+ return parseDisabledToolMap(value as Record<string, unknown>);
422
+ }
423
+
424
+ function loadToolVisibilitySettings(
425
+ settingsPath: string = TOOL_VISIBILITY_SETTINGS_PATH,
426
+ ): LoadedToolVisibilitySettings {
427
+ if (!fs.existsSync(settingsPath)) {
428
+ return { disabledToolKeys: new Set<string>() };
429
+ }
430
+
431
+ try {
432
+ const raw = fs.readFileSync(settingsPath, "utf-8");
433
+ const parsed = JSON.parse(raw) as ToolVisibilitySettingsFile;
434
+ if (!parsed || typeof parsed !== "object") {
435
+ return {
436
+ disabledToolKeys: new Set<string>(),
437
+ warning: `Invalid tool visibility settings format: ${settingsPath}`,
438
+ };
439
+ }
440
+ return { disabledToolKeys: parseDisabledToolKeys(parsed.disabledTools) };
441
+ } catch (error) {
442
+ const message = error instanceof Error ? error.message : String(error);
443
+ return {
444
+ disabledToolKeys: new Set<string>(),
445
+ warning: `Failed to load tool visibility settings (${settingsPath}): ${message}`,
446
+ };
447
+ }
448
+ }
449
+
450
+ export function serializeToolVisibilitySettings(disabledToolKeys: Set<string>): ToolVisibilitySettingsFile {
451
+ const grouped = new Map<string, string[]>();
452
+
453
+ for (const key of disabledToolKeys) {
454
+ const parsed = parseToolVisibilityKey(key);
455
+ if (!parsed) continue;
456
+
457
+ const tools = grouped.get(parsed.serverName) ?? [];
458
+ tools.push(parsed.toolName);
459
+ grouped.set(parsed.serverName, tools);
460
+ }
461
+
462
+ const disabledTools: Record<string, string[]> = {};
463
+ const sortedServers = Array.from(grouped.keys()).sort((a, b) => a.localeCompare(b));
464
+ for (const serverName of sortedServers) {
465
+ const tools = grouped.get(serverName) ?? [];
466
+ const dedupedTools = Array.from(new Set(tools)).sort((a, b) => a.localeCompare(b));
467
+ if (dedupedTools.length > 0) {
468
+ disabledTools[serverName] = dedupedTools;
469
+ }
470
+ }
471
+
472
+ return { disabledTools };
473
+ }
474
+
475
+ function saveToolVisibilitySettings(
476
+ disabledToolKeys: Set<string>,
477
+ settingsPath: string = TOOL_VISIBILITY_SETTINGS_PATH,
478
+ ): { ok: true } | { ok: false; error: string } {
479
+ try {
480
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
481
+ const payload = serializeToolVisibilitySettings(disabledToolKeys);
482
+ fs.writeFileSync(settingsPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
483
+ return { ok: true };
484
+ } catch (error) {
485
+ const message = error instanceof Error ? error.message : String(error);
486
+ return { ok: false, error: message };
487
+ }
488
+ }
489
+
490
+ function hasNewlyDisabledTools(before: Set<string>, after: Set<string>): boolean {
491
+ for (const key of after) {
492
+ if (!before.has(key)) return true;
493
+ }
494
+ return false;
495
+ }
496
+
497
+ export function expandEnvVars(value: string): string {
498
+ return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => process.env[key] ?? "");
499
+ }
500
+
501
+ function expandRecord(input?: Record<string, string>): Record<string, string> {
502
+ const output: Record<string, string> = {};
503
+ if (!input) return output;
504
+ for (const [k, v] of Object.entries(input)) {
505
+ output[k] = expandEnvVars(v);
506
+ }
507
+ return output;
508
+ }
509
+
510
+ function safeReadJson(filePath: string): unknown | null {
511
+ if (!fs.existsSync(filePath)) return null;
512
+ try {
513
+ const raw = fs.readFileSync(filePath, "utf-8");
514
+ return JSON.parse(raw);
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ export function extractRawServers(data: unknown): Record<string, RawMcpServer> | null {
521
+ if (!data || typeof data !== "object") return null;
522
+ const record = data as Record<string, unknown>;
523
+
524
+ if (record.mcpServers && typeof record.mcpServers === "object") {
525
+ return record.mcpServers as Record<string, RawMcpServer>;
526
+ }
527
+
528
+ const mcp = record.mcp as Record<string, unknown> | undefined;
529
+ if (mcp?.servers && typeof mcp.servers === "object") {
530
+ return mcp.servers as Record<string, RawMcpServer>;
531
+ }
532
+
533
+ if (record.servers && typeof record.servers === "object") {
534
+ return record.servers as Record<string, RawMcpServer>;
535
+ }
536
+
537
+ return null;
538
+ }
539
+
540
+ export function normalizeServer(name: string, raw: RawMcpServer): NormalizedMcpServer | null {
541
+ if (raw.enabled === false) return null;
542
+ const type = raw.type?.toLowerCase();
543
+
544
+ if (raw.command || type === "stdio") {
545
+ if (!raw.command) return null;
546
+
547
+ const envFromProcess: Record<string, string> = {};
548
+ for (const [k, v] of Object.entries(process.env)) {
549
+ if (typeof v === "string") envFromProcess[k] = v;
550
+ }
551
+
552
+ return {
553
+ name,
554
+ type: "stdio",
555
+ enabled: true,
556
+ command: expandEnvVars(raw.command),
557
+ args: (raw.args ?? []).map(expandEnvVars),
558
+ env: { ...envFromProcess, ...expandRecord(raw.env) },
559
+ cwd: raw.cwd ? expandEnvVars(raw.cwd) : undefined,
560
+ };
561
+ }
562
+
563
+ if (raw.url) {
564
+ const expandedUrl = expandEnvVars(raw.url);
565
+ const headers = expandRecord(raw.headers);
566
+ const inferred =
567
+ type === "sse" ? "sse" : type === "http" ? "http" : /\/sse(?:\/)?(?:\?|$)/i.test(expandedUrl) ? "sse" : "http";
568
+
569
+ return {
570
+ name,
571
+ type: inferred,
572
+ enabled: true,
573
+ url: expandedUrl,
574
+ headers,
575
+ };
576
+ }
577
+
578
+ return null;
579
+ }
580
+
581
+ function collectScopedConfigCandidates(cwd: string): string[] {
582
+ const candidates: string[] = [];
583
+ const seen = new Set<string>();
584
+
585
+ const push = (candidate: string): void => {
586
+ const resolved = path.resolve(candidate);
587
+ if (seen.has(resolved)) return;
588
+ seen.add(resolved);
589
+ candidates.push(resolved);
590
+ };
591
+
592
+ let current = path.resolve(cwd);
593
+ const home = path.resolve(os.homedir());
594
+ const root = path.parse(current).root;
595
+
596
+ while (true) {
597
+ push(path.join(current, ".pi", "mcp.json"));
598
+ push(path.join(current, ".mcp.json"));
599
+ push(path.join(current, "backend", ".mcp.json"));
600
+ push(path.join(current, "frontend", ".mcp.json"));
601
+
602
+ if (current === home || current === root) break;
603
+ const parent = path.dirname(current);
604
+ if (parent === current) break;
605
+ current = parent;
606
+ }
607
+
608
+ push(path.join(os.homedir(), ".mcp.json"));
609
+ push(path.join(os.homedir(), ".claude.json"));
610
+
611
+ return candidates;
612
+ }
613
+
614
+ function loadConfig(cwd: string): LoadedConfig {
615
+ const warnings: string[] = [];
616
+
617
+ const explicitPath = process.env.PI_MCP_CONFIG;
618
+ const candidates = explicitPath ? [path.resolve(expandEnvVars(explicitPath))] : collectScopedConfigCandidates(cwd);
619
+
620
+ const loadedSources: string[] = [];
621
+ const serversByName = new Map<string, NormalizedMcpServer>();
622
+
623
+ for (const candidate of candidates) {
624
+ const parsed = safeReadJson(candidate);
625
+ if (!parsed) continue;
626
+
627
+ const rawServers = extractRawServers(parsed);
628
+ if (!rawServers || Object.keys(rawServers).length === 0) continue;
629
+ loadedSources.push(candidate);
630
+
631
+ for (const [name, raw] of Object.entries(rawServers)) {
632
+ if (serversByName.has(name)) {
633
+ warnings.push(`Skipped duplicate MCP server config: ${name} (from ${candidate})`);
634
+ continue;
635
+ }
636
+
637
+ const normalized = normalizeServer(name, raw);
638
+ if (normalized) serversByName.set(name, normalized);
639
+ else warnings.push(`Skipped invalid MCP server config: ${name}`);
640
+ }
641
+ }
642
+
643
+ return {
644
+ sourcePath: loadedSources.length > 0 ? loadedSources.join(", ") : null,
645
+ servers: Array.from(serversByName.values()),
646
+ warnings,
647
+ };
648
+ }
649
+
650
+ export function sanitizeName(value: string): string {
651
+ return value
652
+ .toLowerCase()
653
+ .replace(/[^a-z0-9_]+/g, "_")
654
+ .replace(/^_+|_+$/g, "")
655
+ .slice(0, 80);
656
+ }
657
+
658
+ export function buildPiToolName(serverName: string, toolName: string): string {
659
+ const safeServer = sanitizeName(serverName) || "server";
660
+ const safeTool = sanitizeName(toolName) || "tool";
661
+ return `mcp_${safeServer}_${safeTool}`;
662
+ }
663
+
664
+ export function mimeToExt(mimeType: string): string {
665
+ switch (mimeType) {
666
+ case "image/png":
667
+ return "png";
668
+ case "image/jpeg":
669
+ return "jpg";
670
+ case "image/gif":
671
+ return "gif";
672
+ case "image/webp":
673
+ return "webp";
674
+ case "image/svg+xml":
675
+ return "svg";
676
+ default:
677
+ return "png";
678
+ }
679
+ }
680
+
681
+ type FormattedToolResult = { text: string; imagePaths: string[] };
682
+
683
+ type PreparedPayload = {
684
+ text: string;
685
+ truncated: boolean;
686
+ fullPayloadPath?: string;
687
+ originalLength: number;
688
+ };
689
+
690
+ const LARGE_PAYLOAD_THRESHOLD_CHARS = 30_000;
691
+ const LARGE_PAYLOAD_PREVIEW_CHARS = 1_000;
692
+
693
+ function preparePayloadForClient(text: string, serverName: string, toolName: string): PreparedPayload {
694
+ const originalLength = text.length;
695
+ if (originalLength <= LARGE_PAYLOAD_THRESHOLD_CHARS) {
696
+ return { text, truncated: false, originalLength };
697
+ }
698
+
699
+ const preview = text.slice(0, LARGE_PAYLOAD_PREVIEW_CHARS);
700
+ const fileName = `mcp-payload-${sanitizeName(serverName) || "server"}-${sanitizeName(toolName) || "tool"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`;
701
+ const filePath = path.join(os.tmpdir(), fileName);
702
+
703
+ try {
704
+ fs.writeFileSync(filePath, text, "utf-8");
705
+ return {
706
+ text: [
707
+ preview,
708
+ "",
709
+ `[Truncated output: first ${LARGE_PAYLOAD_PREVIEW_CHARS} chars shown, ${originalLength - LARGE_PAYLOAD_PREVIEW_CHARS} chars omitted]`,
710
+ `[Full payload saved to: ${filePath}]`,
711
+ "Use Read tool (or another file reader) to inspect the full payload.",
712
+ ].join("\n"),
713
+ truncated: true,
714
+ fullPayloadPath: filePath,
715
+ originalLength,
716
+ };
717
+ } catch (error) {
718
+ const message = error instanceof Error ? error.message : String(error);
719
+ return {
720
+ text: [
721
+ preview,
722
+ "",
723
+ `[Truncated output: first ${LARGE_PAYLOAD_PREVIEW_CHARS} chars shown, ${originalLength - LARGE_PAYLOAD_PREVIEW_CHARS} chars omitted]`,
724
+ `[Failed to save full payload: ${message}]`,
725
+ ].join("\n"),
726
+ truncated: true,
727
+ originalLength,
728
+ };
729
+ }
730
+ }
731
+
732
+ export function formatToolResult(result: unknown): FormattedToolResult {
733
+ const imagePaths: string[] = [];
734
+
735
+ if (typeof result === "string") return { text: result, imagePaths };
736
+
737
+ if (result && typeof result === "object") {
738
+ const maybe = result as {
739
+ content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
740
+ structuredContent?: unknown;
741
+ };
742
+
743
+ if (Array.isArray(maybe.content)) {
744
+ const chunks = maybe.content
745
+ .map((item) => {
746
+ if (item?.type === "text") return item.text ?? "";
747
+ if (item?.type === "image" && item.data) {
748
+ const ext = mimeToExt(item.mimeType ?? "image/png");
749
+ const tmpFile = path.join(
750
+ os.tmpdir(),
751
+ `mcp-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
752
+ );
753
+ try {
754
+ fs.writeFileSync(tmpFile, Buffer.from(item.data, "base64"));
755
+ imagePaths.push(tmpFile);
756
+ return `[Image saved: ${tmpFile}]`;
757
+ } catch {
758
+ return `[Image save failed: ${item.mimeType}, ${item.data.length} chars]`;
759
+ }
760
+ }
761
+ return JSON.stringify(item);
762
+ })
763
+ .filter(Boolean);
764
+ if (chunks.length > 0) return { text: chunks.join("\n"), imagePaths };
765
+ }
766
+
767
+ if (maybe.structuredContent !== undefined) {
768
+ return { text: JSON.stringify(maybe.structuredContent, null, 2), imagePaths };
769
+ }
770
+ }
771
+
772
+ return { text: JSON.stringify(result, null, 2), imagePaths };
773
+ }
774
+
775
+ type JsonSchemaProp = {
776
+ type?: string;
777
+ description?: string;
778
+ enum?: unknown[];
779
+ items?: { type?: string };
780
+ };
781
+
782
+ /**
783
+ * Map a single JSON Schema property to the appropriate TypeBox type.
784
+ * Preserves type, description, and enum information so the LLM receives
785
+ * accurate type hints and the framework can validate/coerce values.
786
+ */
787
+ function mapPropertyType(prop: JsonSchemaProp): ReturnType<typeof Type.Any> {
788
+ const opts: Record<string, unknown> = {};
789
+ if (typeof prop.description === "string") opts.description = prop.description;
790
+
791
+ switch (prop.type) {
792
+ case "string":
793
+ if (Array.isArray(prop.enum) && prop.enum.every((v): v is string => typeof v === "string")) {
794
+ return Type.Union(
795
+ prop.enum.map((v) => Type.Literal(v)),
796
+ opts,
797
+ ) as unknown as ReturnType<typeof Type.Any>;
798
+ }
799
+ return Type.String(opts) as unknown as ReturnType<typeof Type.Any>;
800
+ case "boolean":
801
+ return Type.Boolean(opts) as unknown as ReturnType<typeof Type.Any>;
802
+ case "number":
803
+ return Type.Number(opts) as unknown as ReturnType<typeof Type.Any>;
804
+ case "integer":
805
+ return Type.Integer(opts) as unknown as ReturnType<typeof Type.Any>;
806
+ case "array":
807
+ return Type.Array(Type.Any(), opts) as unknown as ReturnType<typeof Type.Any>;
808
+ default:
809
+ return Type.Any(opts);
810
+ }
811
+ }
812
+
813
+ export function createParameterSchema(inputSchema: Record<string, unknown>): ReturnType<typeof Type.Object> {
814
+ const schema = inputSchema as {
815
+ type?: string;
816
+ properties?: Record<string, JsonSchemaProp>;
817
+ required?: string[];
818
+ };
819
+
820
+ if (schema.type !== "object" || !schema.properties) {
821
+ return Type.Object({});
822
+ }
823
+
824
+ const required = new Set(schema.required ?? []);
825
+ const properties: Record<string, ReturnType<typeof Type.Any>> = {};
826
+
827
+ for (const [key, prop] of Object.entries(schema.properties)) {
828
+ const base = mapPropertyType(prop);
829
+
830
+ if (required.has(key)) {
831
+ properties[key] = base;
832
+ } else {
833
+ properties[key] = Type.Optional(base) as unknown as ReturnType<typeof Type.Any>;
834
+ }
835
+ }
836
+
837
+ return Type.Object(properties, { additionalProperties: true });
838
+ }
839
+
840
+ function summarizeToolCallArgs(args: Record<string, unknown>, theme: Theme): string {
841
+ const entries = Object.entries(args).filter(([, value]) => value !== undefined && value !== null);
842
+ if (entries.length === 0) return "";
843
+
844
+ const firstEntry = entries[0];
845
+ if (!firstEntry) return "";
846
+
847
+ const [, firstVal] = firstEntry;
848
+ const str = typeof firstVal === "string" ? firstVal : JSON.stringify(firstVal);
849
+ const display = str.length > 80 ? `${str.slice(0, 77)}…` : str;
850
+ const extraCount = entries.length > 1 ? theme.fg("muted", ` +${entries.length - 1}`) : "";
851
+ return ` ${theme.fg("accent", display)}${extraCount}`;
852
+ }
853
+
854
+ function renderMcpToolCall(serverName: string, toolName: string, args: unknown, theme: Theme): Text {
855
+ const label = `${serverName}/${toolName}`;
856
+ const params = args as Record<string, unknown>;
857
+ const argText = summarizeToolCallArgs(params, theme);
858
+ return new Text(`${theme.fg("toolTitle", theme.bold(label))}${argText}`, 0, 0);
859
+ }
860
+
861
+ function buildMcpToolResultContent(
862
+ formatted: FormattedToolResult,
863
+ prepared: PreparedPayload,
864
+ ): Array<{ type: "text"; text: string }> {
865
+ const content: Array<{ type: "text"; text: string }> = [{ type: "text", text: prepared.text }];
866
+ for (const imgPath of formatted.imagePaths) {
867
+ content.push({ type: "text", text: `📎 Use Read tool to view: ${imgPath}` });
868
+ }
869
+ if (prepared.fullPayloadPath) {
870
+ content.push({ type: "text", text: `📄 Full payload file: ${prepared.fullPayloadPath}` });
871
+ }
872
+ return content;
873
+ }
874
+
875
+ type ReloadableContext = ExtensionContext & { reload: () => Promise<void> };
876
+
877
+ async function executeMcpToolCall(args: {
878
+ manager: McpManager;
879
+ serverName: string;
880
+ tool: DiscoveredTool;
881
+ params: Record<string, unknown>;
882
+ signal?: AbortSignal;
883
+ isToolDisabled: (serverName: string, toolName: string) => boolean;
884
+ }) {
885
+ const { manager, serverName, tool, params, signal, isToolDisabled } = args;
886
+ if (signal?.aborted) {
887
+ return {
888
+ content: [{ type: "text" as const, text: "Cancelled" }],
889
+ details: { server: serverName, tool: tool.name, cancelled: true },
890
+ };
891
+ }
892
+ if (isToolDisabled(serverName, tool.name)) {
893
+ throw new Error("This MCP tool is disabled. Open /mcp-status → Tools to enable it.");
894
+ }
895
+
896
+ try {
897
+ const result = await manager.callTool(serverName, tool.name, params);
898
+ const formatted = formatToolResult(result);
899
+ if ((result as { isError?: boolean })?.isError) {
900
+ throw new Error(formatted.text || `MCP tool ${serverName}/${tool.name} returned an error`);
901
+ }
902
+ const prepared = preparePayloadForClient(formatted.text, serverName, tool.name);
903
+ return {
904
+ content: buildMcpToolResultContent(formatted, prepared),
905
+ details: {
906
+ server: serverName,
907
+ tool: tool.name,
908
+ raw: prepared.truncated ? undefined : result,
909
+ payloadTruncated: prepared.truncated,
910
+ payloadOriginalLength: prepared.originalLength,
911
+ payloadFilePath: prepared.fullPayloadPath,
912
+ },
913
+ };
914
+ } catch (error) {
915
+ const message = error instanceof Error ? error.message : String(error);
916
+ throw new Error(`MCP error (${serverName}/${tool.name}): ${message}`);
917
+ }
918
+ }
919
+
920
+ // ── Overlay shared helpers ────────────────────────────────────
921
+
922
+ type McpServerState = {
923
+ name: string;
924
+ status: ServerStatus;
925
+ type: string;
926
+ toolCount: number;
927
+ error?: string;
928
+ };
929
+
930
+ type ServerAction = "tools" | "reconnect";
931
+
932
+ function sColor(status: ServerStatus): "success" | "error" | "warning" | "muted" {
933
+ switch (status) {
934
+ case "connected":
935
+ return "success";
936
+ case "error":
937
+ return "error";
938
+ case "disconnected":
939
+ return "warning";
940
+ default:
941
+ return "muted";
942
+ }
943
+ }
944
+
945
+ function sIcon(status: ServerStatus): string {
946
+ switch (status) {
947
+ case "connected":
948
+ return "●";
949
+ case "error":
950
+ return "✗";
951
+ case "disconnected":
952
+ return "○";
953
+ default:
954
+ return "◐";
955
+ }
956
+ }
957
+
958
+ function boxTop(th: Theme, title: string, innerW: number): string {
959
+ const t = ` ${title} `;
960
+ const tW = visibleWidth(t);
961
+ const p1 = Math.floor((innerW - tW) / 2);
962
+ const p2 = Math.max(0, innerW - tW - p1);
963
+ return th.fg("border", `╭${"─".repeat(p1)}`) + th.fg("accent", th.bold(t)) + th.fg("border", `${"─".repeat(p2)}╮`);
964
+ }
965
+
966
+ function boxSep(th: Theme, innerW: number): string {
967
+ return th.fg("border", `├${"─".repeat(innerW)}┤`);
968
+ }
969
+
970
+ function boxBot(th: Theme, innerW: number): string {
971
+ return th.fg("border", `╰${"─".repeat(innerW)}╯`);
972
+ }
973
+
974
+ function boxRow(th: Theme, content: string, innerW: number): string {
975
+ return th.fg("border", "│") + truncateToWidth(` ${content}`, innerW, "…", true) + th.fg("border", "│");
976
+ }
977
+
978
+ // ── Overlay 1: Server list (navigable) ───────────────────────
979
+
980
+ class McpStatusOverlay {
981
+ private tui: TUI;
982
+ private theme: Theme;
983
+ private done: (value: string | null) => void;
984
+ private states: McpServerState[];
985
+ private sourcePath: string | null;
986
+ private warnings: string[];
987
+ private sel = 0;
988
+
989
+ constructor(
990
+ tui: TUI,
991
+ theme: Theme,
992
+ done: (value: string | null) => void,
993
+ states: McpServerState[],
994
+ sourcePath: string | null,
995
+ warnings: string[],
996
+ ) {
997
+ this.tui = tui;
998
+ this.theme = theme;
999
+ this.done = done;
1000
+ this.states = states;
1001
+ this.sourcePath = sourcePath;
1002
+ this.warnings = warnings;
1003
+ }
1004
+
1005
+ handleInput(data: string): void {
1006
+ if (matchesKey(data, "escape") || data === "q") {
1007
+ this.done(null);
1008
+ } else if (matchesKey(data, "up") || data === "k") {
1009
+ this.sel = Math.max(0, this.sel - 1);
1010
+ this.tui.requestRender();
1011
+ } else if (matchesKey(data, "down") || data === "j") {
1012
+ this.sel = Math.min(this.states.length - 1, this.sel + 1);
1013
+ this.tui.requestRender();
1014
+ } else if (matchesKey(data, "return")) {
1015
+ if (this.states.length > 0) this.done(this.states[this.sel]?.name);
1016
+ }
1017
+ }
1018
+
1019
+ invalidate(): void {}
1020
+
1021
+ render(width: number): string[] {
1022
+ const th = this.theme;
1023
+ const iW = Math.max(1, width - 2);
1024
+ const lines: string[] = [];
1025
+
1026
+ lines.push(boxTop(th, "MCP Server Status", iW));
1027
+ if (this.sourcePath) {
1028
+ lines.push(boxRow(th, th.fg("muted", `Source: ${this.sourcePath}`), iW));
1029
+ }
1030
+ lines.push(boxSep(th, iW));
1031
+
1032
+ for (let i = 0; i < this.states.length; i++) {
1033
+ const st = this.states[i];
1034
+ if (!st) continue;
1035
+ const c = sColor(st.status);
1036
+ const ico = sIcon(st.status);
1037
+ const sel = i === this.sel;
1038
+ const cursor = sel ? th.fg("accent", "▸") : " ";
1039
+ const name = sel ? th.fg("accent", th.bold(st.name)) : st.name;
1040
+ const tools = st.toolCount > 0 ? th.fg("muted", ` ${st.toolCount} tools`) : "";
1041
+ const err = st.error ? ` ${th.fg("error", "⚠")}` : "";
1042
+ lines.push(boxRow(th, `${cursor} ${th.fg(c, ico)} ${name} ${th.fg("muted", st.type)}${tools}${err}`, iW));
1043
+ }
1044
+
1045
+ if (this.warnings.length > 0) {
1046
+ lines.push(boxSep(th, iW));
1047
+ lines.push(boxRow(th, th.fg("warning", `⚠ ${this.warnings.length} warning(s)`), iW));
1048
+ }
1049
+
1050
+ lines.push(boxSep(th, iW));
1051
+ lines.push(boxRow(th, th.fg("muted", "↑↓ navigate · enter select · ESC close"), iW));
1052
+ lines.push(boxBot(th, iW));
1053
+ return lines;
1054
+ }
1055
+ }
1056
+
1057
+ // ── Overlay 2: Server action menu ────────────────────────────
1058
+
1059
+ class McpActionOverlay {
1060
+ private tui: TUI;
1061
+ private theme: Theme;
1062
+ private done: (value: ServerAction | null) => void;
1063
+ private state: McpServerState;
1064
+ private actions: Array<{ id: ServerAction; label: string; hint: string }> = [
1065
+ { id: "tools", label: "Tools", hint: "Enable/disable tools" },
1066
+ { id: "reconnect", label: "Reconnect", hint: "Disconnect & reconnect" },
1067
+ ];
1068
+ private sel = 0;
1069
+
1070
+ constructor(tui: TUI, theme: Theme, done: (value: ServerAction | null) => void, state: McpServerState) {
1071
+ this.tui = tui;
1072
+ this.theme = theme;
1073
+ this.done = done;
1074
+ this.state = state;
1075
+ }
1076
+
1077
+ handleInput(data: string): void {
1078
+ if (matchesKey(data, "escape")) {
1079
+ this.done(null);
1080
+ } else if (matchesKey(data, "up") || data === "k") {
1081
+ this.sel = Math.max(0, this.sel - 1);
1082
+ this.tui.requestRender();
1083
+ } else if (matchesKey(data, "down") || data === "j") {
1084
+ this.sel = Math.min(this.actions.length - 1, this.sel + 1);
1085
+ this.tui.requestRender();
1086
+ } else if (matchesKey(data, "return")) {
1087
+ this.done(this.actions[this.sel]?.id);
1088
+ }
1089
+ }
1090
+
1091
+ invalidate(): void {}
1092
+
1093
+ render(width: number): string[] {
1094
+ const th = this.theme;
1095
+ const iW = Math.max(1, width - 2);
1096
+ const st = this.state;
1097
+ const c = sColor(st.status);
1098
+ const ico = sIcon(st.status);
1099
+ const lines: string[] = [];
1100
+
1101
+ lines.push(boxTop(th, st.name, iW));
1102
+ lines.push(boxRow(th, `${th.fg(c, `${ico} ${st.status}`)} ${th.fg("muted", st.type)}`, iW));
1103
+ if (st.toolCount > 0) {
1104
+ lines.push(boxRow(th, th.fg("muted", `${st.toolCount} tools registered`), iW));
1105
+ }
1106
+ if (st.error) {
1107
+ lines.push(boxRow(th, th.fg("error", `⚠ ${st.error}`), iW));
1108
+ }
1109
+ lines.push(boxSep(th, iW));
1110
+
1111
+ for (let i = 0; i < this.actions.length; i++) {
1112
+ const a = this.actions[i];
1113
+ if (!a) continue;
1114
+ const sel = i === this.sel;
1115
+ const cursor = sel ? th.fg("accent", "▸") : " ";
1116
+ const label = sel ? th.fg("accent", th.bold(a.label)) : a.label;
1117
+ lines.push(boxRow(th, `${cursor} ${label} ${th.fg("muted", a.hint)}`, iW));
1118
+ }
1119
+
1120
+ lines.push(boxSep(th, iW));
1121
+ lines.push(boxRow(th, th.fg("muted", "↑↓ navigate · enter select · ESC back"), iW));
1122
+ lines.push(boxBot(th, iW));
1123
+ return lines;
1124
+ }
1125
+ }
1126
+
1127
+ // ── Overlay 3: Tool list ─────────────────────────────────────
1128
+
1129
+ class McpToolListOverlay {
1130
+ private tui: TUI;
1131
+ private theme: Theme;
1132
+ private onClose: () => void;
1133
+ private serverName: string;
1134
+ private tools: DiscoveredTool[];
1135
+ private isToolDisabled: (toolName: string) => boolean;
1136
+ private onToggleTool: (toolName: string) => void;
1137
+ private sel = 0;
1138
+ private scroll = 0;
1139
+ private maxVisible = 15;
1140
+
1141
+ constructor(
1142
+ tui: TUI,
1143
+ theme: Theme,
1144
+ onClose: () => void,
1145
+ serverName: string,
1146
+ tools: DiscoveredTool[],
1147
+ isToolDisabled: (toolName: string) => boolean,
1148
+ onToggleTool: (toolName: string) => void,
1149
+ ) {
1150
+ this.tui = tui;
1151
+ this.theme = theme;
1152
+ this.onClose = onClose;
1153
+ this.serverName = serverName;
1154
+ this.tools = tools;
1155
+ this.isToolDisabled = isToolDisabled;
1156
+ this.onToggleTool = onToggleTool;
1157
+ }
1158
+
1159
+ private ensureSelectionVisible(): void {
1160
+ if (this.sel < this.scroll) {
1161
+ this.scroll = this.sel;
1162
+ return;
1163
+ }
1164
+ if (this.sel >= this.scroll + this.maxVisible) {
1165
+ this.scroll = this.sel - this.maxVisible + 1;
1166
+ }
1167
+ }
1168
+
1169
+ handleInput(data: string): void {
1170
+ if (matchesKey(data, "escape")) {
1171
+ this.onClose();
1172
+ return;
1173
+ }
1174
+
1175
+ if (this.tools.length === 0) return;
1176
+
1177
+ if (matchesKey(data, "up") || data === "k") {
1178
+ this.sel = Math.max(0, this.sel - 1);
1179
+ this.ensureSelectionVisible();
1180
+ this.tui.requestRender();
1181
+ return;
1182
+ }
1183
+
1184
+ if (matchesKey(data, "down") || data === "j") {
1185
+ this.sel = Math.min(this.tools.length - 1, this.sel + 1);
1186
+ this.ensureSelectionVisible();
1187
+ this.tui.requestRender();
1188
+ return;
1189
+ }
1190
+
1191
+ if (matchesKey(data, "return") || data === " ") {
1192
+ const tool = this.tools[this.sel];
1193
+ if (!tool) return;
1194
+ this.onToggleTool(tool.name);
1195
+ this.tui.requestRender();
1196
+ }
1197
+ }
1198
+
1199
+ invalidate(): void {}
1200
+
1201
+ render(width: number): string[] {
1202
+ const th = this.theme;
1203
+ const iW = Math.max(1, width - 2);
1204
+ const lines: string[] = [];
1205
+ const enabledCount = this.tools.filter((tool) => !this.isToolDisabled(tool.name)).length;
1206
+
1207
+ lines.push(boxTop(th, `${this.serverName} · Tools`, iW));
1208
+ lines.push(boxRow(th, th.fg("muted", `Enabled ${enabledCount}/${this.tools.length}`), iW));
1209
+ lines.push(boxSep(th, iW));
1210
+
1211
+ if (this.tools.length === 0) {
1212
+ lines.push(boxRow(th, th.fg("muted", "No tools available"), iW));
1213
+ } else {
1214
+ const from = this.scroll;
1215
+ const to = Math.min(this.tools.length, this.scroll + this.maxVisible);
1216
+ for (let i = from; i < to; i++) {
1217
+ const tool = this.tools[i];
1218
+ if (!tool) continue;
1219
+ const selected = i === this.sel;
1220
+ const cursor = selected ? th.fg("accent", "▸") : " ";
1221
+ const piName = buildPiToolName(this.serverName, tool.name);
1222
+ const name = selected ? th.fg("accent", th.bold(piName)) : piName;
1223
+ const enabled = !this.isToolDisabled(tool.name);
1224
+ const state = enabled ? th.fg("success", "● on") : th.fg("muted", "○ off");
1225
+ const desc = tool.description ? ` ${th.fg("muted", `— ${tool.description}`)}` : "";
1226
+ lines.push(boxRow(th, `${cursor} ${state} ${name}${desc}`, iW));
1227
+ }
1228
+ if (this.tools.length > this.maxVisible) {
1229
+ lines.push(boxSep(th, iW));
1230
+ const info = `${from + 1}–${to} of ${this.tools.length}`;
1231
+ lines.push(boxRow(th, th.fg("muted", info), iW));
1232
+ }
1233
+ }
1234
+
1235
+ lines.push(boxSep(th, iW));
1236
+ lines.push(boxRow(th, th.fg("muted", "↑↓ navigate · Enter/Space toggle · ESC back"), iW));
1237
+ lines.push(boxBot(th, iW));
1238
+ return lines;
1239
+ }
1240
+ }
1241
+
1242
+ export default async function claudeMcpBridge(pi: ExtensionAPI) {
1243
+ const manager = new McpManager();
1244
+ const registeredTools = new Set<string>();
1245
+ let loadedAt: LoadedConfig = { sourcePath: null, servers: [], warnings: [] };
1246
+ const loadedToolVisibility = loadToolVisibilitySettings();
1247
+ const disabledToolKeys = loadedToolVisibility.disabledToolKeys;
1248
+ let toolVisibilityWarning = loadedToolVisibility.warning;
1249
+
1250
+ function getOverlayWarnings(): string[] {
1251
+ const warnings = [...loadedAt.warnings];
1252
+ if (toolVisibilityWarning) warnings.push(toolVisibilityWarning);
1253
+ return warnings;
1254
+ }
1255
+
1256
+ function updateStatus(ctx: ExtensionContext): void {
1257
+ if (!ctx.hasUI) return;
1258
+ const states = manager.getStates();
1259
+ const total = states.length;
1260
+ if (total === 0) {
1261
+ ctx.ui.setStatus("mcp", undefined);
1262
+ return;
1263
+ }
1264
+ const connected = states.filter((s) => s.status === "connected").length;
1265
+ ctx.ui.setStatus("mcp", `MCP ${connected}/${total}`);
1266
+ }
1267
+
1268
+ function isToolDisabled(serverName: string, toolName: string): boolean {
1269
+ return disabledToolKeys.has(buildToolVisibilityKey(serverName, toolName));
1270
+ }
1271
+
1272
+ function removeDisabledToolsFromActiveSet(): void {
1273
+ let activeTools: Set<string>;
1274
+ try {
1275
+ activeTools = new Set(pi.getActiveTools());
1276
+ } catch {
1277
+ return;
1278
+ }
1279
+
1280
+ let changed = false;
1281
+
1282
+ for (const { serverName, tool } of manager.getAllTools()) {
1283
+ if (!isToolDisabled(serverName, tool.name)) continue;
1284
+ const piToolName = buildPiToolName(serverName, tool.name);
1285
+ if (activeTools.delete(piToolName)) changed = true;
1286
+ }
1287
+
1288
+ if (changed) {
1289
+ pi.setActiveTools(Array.from(activeTools));
1290
+ }
1291
+ }
1292
+
1293
+ function setToolActive(piToolName: string, enabled: boolean): void {
1294
+ let activeTools: Set<string>;
1295
+ try {
1296
+ activeTools = new Set(pi.getActiveTools());
1297
+ } catch {
1298
+ return;
1299
+ }
1300
+
1301
+ let changed = false;
1302
+ if (enabled) {
1303
+ if (!activeTools.has(piToolName)) {
1304
+ activeTools.add(piToolName);
1305
+ changed = true;
1306
+ }
1307
+ } else if (activeTools.delete(piToolName)) {
1308
+ changed = true;
1309
+ }
1310
+
1311
+ if (changed) {
1312
+ pi.setActiveTools(Array.from(activeTools));
1313
+ }
1314
+ }
1315
+
1316
+ function toggleToolDisabled(
1317
+ serverName: string,
1318
+ toolName: string,
1319
+ ): { ok: true; disabled: boolean } | { ok: false; error: string } {
1320
+ const key = buildToolVisibilityKey(serverName, toolName);
1321
+ const currentlyDisabled = disabledToolKeys.has(key);
1322
+ const nextDisabled = !currentlyDisabled;
1323
+
1324
+ if (nextDisabled) {
1325
+ disabledToolKeys.add(key);
1326
+ } else {
1327
+ disabledToolKeys.delete(key);
1328
+ }
1329
+
1330
+ const saved = saveToolVisibilitySettings(disabledToolKeys);
1331
+ if (!saved.ok) {
1332
+ if (currentlyDisabled) {
1333
+ disabledToolKeys.add(key);
1334
+ } else {
1335
+ disabledToolKeys.delete(key);
1336
+ }
1337
+ return { ok: false, error: saved.error };
1338
+ }
1339
+
1340
+ toolVisibilityWarning = undefined;
1341
+ return { ok: true, disabled: nextDisabled };
1342
+ }
1343
+
1344
+ function notifyStatusSummary(ctx: ExtensionContext): void {
1345
+ const states = manager.getStates();
1346
+ const summary = states.map((s) => `${s.name}=${s.status}${s.toolCount > 0 ? `(${s.toolCount})` : ""}`).join(", ");
1347
+ const sourceText = manager.sourcePath ? ` | source: ${manager.sourcePath}` : "";
1348
+ const disabledCount = manager
1349
+ .getAllTools()
1350
+ .filter(({ serverName, tool }) => isToolDisabled(serverName, tool.name)).length;
1351
+ const disabledText = disabledCount > 0 ? ` | disabled tools: ${disabledCount}` : "";
1352
+ ctx.ui.notify(`MCP: ${summary}${sourceText}${disabledText}`, "info");
1353
+ }
1354
+
1355
+ function handleToolToggle(
1356
+ ctx: ExtensionContext,
1357
+ serverName: string,
1358
+ toolName: string,
1359
+ setReloadNeeded: (value: boolean) => void,
1360
+ ): void {
1361
+ const toggled = toggleToolDisabled(serverName, toolName);
1362
+ if (!toggled.ok) {
1363
+ ctx.ui.notify(`Failed to save MCP tool settings: ${toggled.error}`, "warning");
1364
+ return;
1365
+ }
1366
+
1367
+ registerDiscoveredTools();
1368
+ removeDisabledToolsFromActiveSet();
1369
+
1370
+ const piToolName = buildPiToolName(serverName, toolName);
1371
+ if (toggled.disabled) {
1372
+ if (registeredTools.has(piToolName)) {
1373
+ setReloadNeeded(true);
1374
+ }
1375
+ setToolActive(piToolName, false);
1376
+ ctx.ui.notify(`${piToolName}: disabled`, "info");
1377
+ return;
1378
+ }
1379
+
1380
+ if (registeredTools.has(piToolName)) {
1381
+ setToolActive(piToolName, true);
1382
+ ctx.ui.notify(`${piToolName}: enabled`, "info");
1383
+ return;
1384
+ }
1385
+ ctx.ui.notify(`${piToolName}: enabled (connect or reload to register)`, "warning");
1386
+ }
1387
+
1388
+ async function showToolOverlay(
1389
+ ctx: ExtensionContext,
1390
+ serverName: string,
1391
+ setReloadNeeded: (value: boolean) => void,
1392
+ ): Promise<void> {
1393
+ const tools = manager.getServerTools(serverName);
1394
+ await ctx.ui.custom<null>(
1395
+ (tui, theme, _kb, done) =>
1396
+ new McpToolListOverlay(
1397
+ tui,
1398
+ theme,
1399
+ () => done(null),
1400
+ serverName,
1401
+ tools,
1402
+ (toolName) => isToolDisabled(serverName, toolName),
1403
+ (toolName) => handleToolToggle(ctx, serverName, toolName, setReloadNeeded),
1404
+ ),
1405
+ { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 50, maxHeight: "80%" } },
1406
+ );
1407
+ }
1408
+
1409
+ async function reconnectSelectedServer(ctx: ExtensionContext, serverName: string): Promise<void> {
1410
+ await manager.reconnectServer(serverName);
1411
+ registerDiscoveredTools();
1412
+ removeDisabledToolsFromActiveSet();
1413
+ updateStatus(ctx);
1414
+ const updated = manager.getStates().find((s) => s.name === serverName);
1415
+ if (updated?.status === "connected") {
1416
+ ctx.ui.notify(`${serverName}: reconnected (${updated.toolCount} tools)`, "info");
1417
+ return;
1418
+ }
1419
+ ctx.ui.notify(
1420
+ `${serverName}: ${updated?.status ?? "unknown"}${updated?.error ? ` – ${updated.error}` : ""}`,
1421
+ "warning",
1422
+ );
1423
+ }
1424
+
1425
+ async function openMcpStatusOverlay(ctx: ReloadableContext, disabledAtCommandStart: Set<string>): Promise<void> {
1426
+ let shouldReloadForVisibility = false;
1427
+ const setReloadNeeded = (value: boolean) => {
1428
+ if (value) shouldReloadForVisibility = true;
1429
+ };
1430
+
1431
+ serverList: while (true) {
1432
+ const freshStates = manager.getStates();
1433
+ const serverName = await ctx.ui.custom<string | null>(
1434
+ (tui, theme, _kb, done) =>
1435
+ new McpStatusOverlay(tui, theme, done, freshStates, manager.sourcePath, getOverlayWarnings()),
1436
+ { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 50, maxHeight: "80%" } },
1437
+ );
1438
+ if (!serverName) break;
1439
+
1440
+ while (true) {
1441
+ const serverState = manager.getStates().find((s) => s.name === serverName);
1442
+ if (!serverState) break;
1443
+
1444
+ const action = await ctx.ui.custom<ServerAction | null>(
1445
+ (tui, theme, _kb, done) => new McpActionOverlay(tui, theme, done, serverState),
1446
+ { overlay: true, overlayOptions: { anchor: "center", width: "80%", minWidth: 50, maxHeight: "80%" } },
1447
+ );
1448
+ if (action === "tools") {
1449
+ await showToolOverlay(ctx, serverName, setReloadNeeded);
1450
+ continue;
1451
+ }
1452
+ if (action === "reconnect") {
1453
+ await reconnectSelectedServer(ctx, serverName);
1454
+ continue serverList;
1455
+ }
1456
+ continue serverList;
1457
+ }
1458
+ }
1459
+
1460
+ if (shouldReloadForVisibility || hasNewlyDisabledTools(disabledAtCommandStart, disabledToolKeys)) {
1461
+ ctx.ui.notify("Reloading runtime to hide disabled MCP tools...", "info");
1462
+ await ctx.reload();
1463
+ }
1464
+ }
1465
+
1466
+ function registerDiscoveredTools(): void {
1467
+ for (const { serverName, tool } of manager.getAllTools()) {
1468
+ if (isToolDisabled(serverName, tool.name)) continue;
1469
+
1470
+ const piToolName = buildPiToolName(serverName, tool.name);
1471
+ if (registeredTools.has(piToolName)) continue;
1472
+
1473
+ pi.registerTool({
1474
+ name: piToolName,
1475
+ label: `MCP ${serverName}/${tool.name}`,
1476
+ description: tool.description ?? `MCP tool ${serverName}/${tool.name}`,
1477
+ parameters: createParameterSchema(tool.inputSchema),
1478
+
1479
+ renderCall(args, theme) {
1480
+ return renderMcpToolCall(serverName, tool.name, args, theme);
1481
+ },
1482
+
1483
+ renderResult(result, { expanded }, theme) {
1484
+ const tc = result.content.find((c) => c.type === "text");
1485
+ if (!expanded) {
1486
+ if (tc?.type === "text") {
1487
+ const count = tc.text.trim().split("\n").filter(Boolean).length;
1488
+ if (count > 0) return new Text(theme.fg("muted", ` → ${count} lines`), 0, 0);
1489
+ }
1490
+ return new Text("", 0, 0);
1491
+ }
1492
+ if (!tc || tc.type !== "text") return new Text("", 0, 0);
1493
+ const output = tc.text
1494
+ .trim()
1495
+ .split("\n")
1496
+ .map((line) => theme.fg("toolOutput", line))
1497
+ .join("\n");
1498
+ return output ? new Text(`\n${output}`, 0, 0) : new Text("", 0, 0);
1499
+ },
1500
+
1501
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
1502
+ onUpdate?.({
1503
+ content: [{ type: "text" as const, text: `Calling MCP ${serverName}/${tool.name}...` }],
1504
+ details: { server: serverName, tool: tool.name, status: "running" },
1505
+ });
1506
+ return executeMcpToolCall({
1507
+ manager,
1508
+ serverName,
1509
+ tool,
1510
+ params: params as Record<string, unknown>,
1511
+ signal,
1512
+ isToolDisabled,
1513
+ });
1514
+ },
1515
+ });
1516
+
1517
+ registeredTools.add(piToolName);
1518
+ }
1519
+ }
1520
+
1521
+ async function loadAndConnect(cwd: string): Promise<LoadedConfig> {
1522
+ const loaded = loadConfig(cwd);
1523
+ await manager.replaceServers(loaded.servers, loaded.sourcePath);
1524
+ await manager.connectAll();
1525
+ registerDiscoveredTools();
1526
+ loadedAt = loaded;
1527
+ return loaded;
1528
+ }
1529
+
1530
+ // IMPORTANT: register MCP tools during extension load so pi includes them in tool registry.
1531
+ // NOTE(user-approved): 초기 연결 실패 시 재시도/도구 재등록 강화는 현재 동작을 유지한다.
1532
+ await loadAndConnect(process.cwd());
1533
+
1534
+ pi.on("session_start", async (_event, ctx) => {
1535
+ updateStatus(ctx);
1536
+ removeDisabledToolsFromActiveSet();
1537
+ if (toolVisibilityWarning && ctx.hasUI) {
1538
+ ctx.ui.notify(`[claude-mcp-bridge] ${toolVisibilityWarning}`, "warning");
1539
+ }
1540
+ });
1541
+
1542
+ pi.on("session_shutdown", async () => {
1543
+ await manager.disconnectAll();
1544
+ });
1545
+
1546
+ pi.registerCommand("mcp-status", {
1547
+ description: "Show MCP server connection status",
1548
+ handler: async (_args, ctx) => {
1549
+ if (manager.getStates().length === 0) {
1550
+ ctx.ui.notify("MCP: no configured servers", "warning");
1551
+ return;
1552
+ }
1553
+
1554
+ const disabledAtCommandStart = new Set(disabledToolKeys);
1555
+ if (!ctx.hasUI) {
1556
+ notifyStatusSummary(ctx);
1557
+ return;
1558
+ }
1559
+ await openMcpStatusOverlay(ctx as ReloadableContext, disabledAtCommandStart);
1560
+ },
1561
+ });
1562
+ }