@fitchmultz/pi-mcp-adapter 6.0.0 → 7.0.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 (105) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -8
  3. package/dist/app-bridge.bundle.js +14 -14
  4. package/dist/config.js +41 -16
  5. package/dist/gateway-arguments.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lifecycle.d.ts +2 -2
  8. package/dist/lifecycle.js +2 -2
  9. package/dist/mcp-auth-flow.js +2 -2
  10. package/dist/mcp-auth.d.ts +1 -1
  11. package/dist/mcp-auth.js +3 -3
  12. package/dist/mcp-code.d.ts +6 -6
  13. package/dist/mcp-code.js +11 -10
  14. package/dist/mcp-oauth-provider.d.ts +5 -5
  15. package/dist/mcp-oauth-provider.js +5 -5
  16. package/dist/mcp-output-guard.d.ts +1 -1
  17. package/dist/mcp-panel.d.ts +2 -2
  18. package/dist/mcp-panel.js +2 -2
  19. package/dist/mcp-setup-panel.d.ts +5 -4
  20. package/dist/mcp-setup-panel.js +15 -7
  21. package/dist/mcp-trace.d.ts +1 -1
  22. package/dist/mcp-trace.js +1 -1
  23. package/dist/npx-resolver.js +8 -4
  24. package/dist/proxy-modes.d.ts +2 -2
  25. package/dist/proxy-modes.js +9 -9
  26. package/dist/sampling-handler.js +1 -0
  27. package/dist/search-ranking.js +6 -2
  28. package/dist/server-manager.d.ts +2 -2
  29. package/dist/server-manager.js +44 -27
  30. package/dist/session-recovery.d.ts +7 -4
  31. package/dist/session-recovery.js +76 -10
  32. package/dist/tool-metadata.d.ts +43 -43
  33. package/dist/tool-result-renderer.d.ts +3 -3
  34. package/dist/tool-result-renderer.js +3 -3
  35. package/dist/ui-resource-handler.d.ts +2 -2
  36. package/dist/ui-resource-handler.js +2 -2
  37. package/dist/ui-server.js +4 -3
  38. package/dist/ui-stream-types.d.ts +8 -8
  39. package/dist/unix-socket-transport.d.ts +1 -1
  40. package/dist/unix-socket-transport.js +1 -1
  41. package/dist/utils.d.ts +1 -0
  42. package/dist/utils.js +14 -0
  43. package/package.json +34 -98
  44. package/abort.ts +0 -34
  45. package/agent-dir.ts +0 -24
  46. package/app-bridge.bundle.js +0 -109
  47. package/banner.png +0 -0
  48. package/checkpoint.ts +0 -55
  49. package/commands.ts +0 -622
  50. package/config.ts +0 -1204
  51. package/consent-manager.ts +0 -25
  52. package/direct-tools.ts +0 -75
  53. package/elicitation-handler.ts +0 -349
  54. package/error-signal.ts +0 -21
  55. package/gateway-arguments.ts +0 -73
  56. package/glimpse-ui.ts +0 -80
  57. package/host-html-template.ts +0 -456
  58. package/index.ts +0 -767
  59. package/init.ts +0 -613
  60. package/legacy-migration.ts +0 -47
  61. package/lifecycle.ts +0 -165
  62. package/logger.ts +0 -127
  63. package/mcp-auth-flow.ts +0 -975
  64. package/mcp-auth.ts +0 -793
  65. package/mcp-callback-server.ts +0 -450
  66. package/mcp-code.ts +0 -412
  67. package/mcp-keyring-helper.cjs +0 -89
  68. package/mcp-oauth-provider.ts +0 -774
  69. package/mcp-output-guard.ts +0 -530
  70. package/mcp-panel.ts +0 -907
  71. package/mcp-script-worker.mjs +0 -131
  72. package/mcp-setup-panel.ts +0 -666
  73. package/mcp-status.ts +0 -110
  74. package/mcp-trace.ts +0 -295
  75. package/metadata-cache.ts +0 -271
  76. package/npx-resolver.ts +0 -488
  77. package/onboarding-state.ts +0 -58
  78. package/panel-keys.ts +0 -37
  79. package/prompts.ts +0 -337
  80. package/proxy-modes.ts +0 -1109
  81. package/resource-tools.ts +0 -52
  82. package/runtime-owner.ts +0 -123
  83. package/sampling-handler.ts +0 -276
  84. package/scripts/build-app-bridge.mjs +0 -15
  85. package/scripts/build.mjs +0 -132
  86. package/scripts/prepare.mjs +0 -103
  87. package/search-ranking.ts +0 -132
  88. package/server-manager.ts +0 -1320
  89. package/session-recovery.ts +0 -316
  90. package/state.ts +0 -64
  91. package/tool-approval.ts +0 -78
  92. package/tool-loader.ts +0 -153
  93. package/tool-metadata.ts +0 -240
  94. package/tool-registrar.ts +0 -74
  95. package/tool-result-renderer.ts +0 -294
  96. package/tsconfig.build.json +0 -13
  97. package/tsconfig.json +0 -16
  98. package/types.ts +0 -785
  99. package/ui-resource-handler.ts +0 -249
  100. package/ui-server.ts +0 -808
  101. package/ui-session.ts +0 -568
  102. package/ui-stream-types.ts +0 -89
  103. package/ui-tool-visibility.ts +0 -25
  104. package/unix-socket-transport.ts +0 -85
  105. package/utils.ts +0 -307
package/mcp-panel.ts DELETED
@@ -1,907 +0,0 @@
1
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { copyToClipboard } from "@earendil-works/pi-coding-agent";
3
- import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
4
- import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerEntry, ServerProvenance, ToolPrefix } from "./types.ts";
5
- import { sanitizeTerminalText, stripOscSequences } from "./utils.ts";
6
- import { reconstructToolMetadata, type MetadataCache, type ServerCacheEntry, type CachedTool } from "./metadata-cache.ts";
7
-
8
- interface PanelTheme {
9
- border: string;
10
- title: string;
11
- selected: string;
12
- direct: string;
13
- needsAuth: string;
14
- placeholder: string;
15
- description: string;
16
- hint: string;
17
- confirm: string;
18
- cancel: string;
19
- }
20
-
21
- const DEFAULT_THEME: PanelTheme = {
22
- border: "2",
23
- title: "2",
24
- selected: "36",
25
- direct: "32",
26
- needsAuth: "33",
27
- placeholder: "2;3",
28
- description: "2",
29
- hint: "2",
30
- confirm: "32",
31
- cancel: "31",
32
- };
33
-
34
- function fg(code: string, text: string): string {
35
- if (!code) return text;
36
- return `\x1b[${code}m${text}\x1b[0m`;
37
- }
38
-
39
- const RAINBOW_COLORS = [
40
- "38;2;178;129;214",
41
- "38;2;215;135;175",
42
- "38;2;254;188;56",
43
- "38;2;228;192;15",
44
- "38;2;137;210;129",
45
- "38;2;0;175;175",
46
- "38;2;23;143;185",
47
- ];
48
-
49
- function rainbowProgress(filled: number, total: number): string {
50
- const dots: string[] = [];
51
- for (let i = 0; i < total; i++) {
52
- const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length];
53
- if (!color) continue;
54
- dots.push(fg(color, i < filled ? "●" : "○"));
55
- }
56
- return dots.join(" ");
57
- }
58
-
59
- function fuzzyScore(query: string, text: string): number {
60
- const lq = query.toLowerCase();
61
- const lt = text.toLowerCase();
62
- if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
63
- let score = 0;
64
- let qi = 0;
65
- let consecutive = 0;
66
- for (let i = 0; i < lt.length && qi < lq.length; i++) {
67
- if (lt[i] === lq[qi]) {
68
- score += 10 + consecutive;
69
- consecutive += 5;
70
- qi++;
71
- } else {
72
- consecutive = 0;
73
- }
74
- }
75
- return qi === lq.length ? score : 0;
76
- }
77
-
78
- function sanitizeDisplayText(text: string | null | undefined): string {
79
- return sanitizeTerminalText(text ?? "");
80
- }
81
-
82
- function sanitizeRowContent(content: string): string {
83
- const withoutOsc = stripOscSequences(content);
84
- let result = "";
85
- let pendingSpace = false;
86
- for (let i = 0; i < withoutOsc.length; i++) {
87
- const rest = withoutOsc.slice(i);
88
- const ansi = rest.match(/^(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_])/);
89
- if (ansi) {
90
- result += ansi[0];
91
- i += ansi[0].length - 1;
92
- continue;
93
- }
94
-
95
- const code = withoutOsc.charCodeAt(i);
96
- if (code <= 0x1f || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
97
- pendingSpace = true;
98
- continue;
99
- }
100
-
101
- if (pendingSpace && result && !result.endsWith(" ")) {
102
- result += " ";
103
- }
104
- pendingSpace = false;
105
- result += withoutOsc[i];
106
- }
107
- return result;
108
- }
109
-
110
- function estimateTokens(tool: CachedTool): number {
111
- const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
112
- const descLen = tool.description?.length ?? 0;
113
- return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
114
- }
115
-
116
- type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting" | "disabled";
117
-
118
- interface ToolState {
119
- name: string;
120
- description: string;
121
- isDirect: boolean;
122
- wasDirect: boolean;
123
- estimatedTokens: number;
124
- }
125
-
126
- interface ServerState {
127
- name: string;
128
- expanded: boolean;
129
- source: "user" | "project" | "import";
130
- importKind?: string;
131
- definition: ServerEntry;
132
- toolFilter: true | string[] | false;
133
- resourceCount: number;
134
- connectionStatus: ConnectionStatus;
135
- failureMessage?: string | null;
136
- tools: ToolState[];
137
- hasCachedData: boolean;
138
- }
139
-
140
- interface VisibleItem {
141
- type: "server" | "tool";
142
- serverIndex: number;
143
- toolIndex?: number;
144
- }
145
-
146
- class McpPanel {
147
- private noticeLines: string[];
148
- private prefix: ToolPrefix;
149
- private servers: ServerState[] = [];
150
- private cursorIndex = 0;
151
- private nameQuery = "";
152
- private descSearchActive = false;
153
- private descQuery = "";
154
- private dirty = false;
155
- private confirmingDiscard = false;
156
- private discardSelected = 1;
157
- private importNotice: string | null = null;
158
- private authNotice: string | null = null;
159
- private authInFlight: string | null = null;
160
- private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
161
- private visibleItems: VisibleItem[] = [];
162
- private tui: { requestRender(): void };
163
- private t = DEFAULT_THEME;
164
- private authOnly: boolean;
165
- private keys: PanelKeys;
166
-
167
- private static readonly MAX_VISIBLE = 12;
168
- private static readonly INACTIVITY_MS = 60_000;
169
-
170
- constructor(
171
- config: McpConfig,
172
- cache: MetadataCache | null,
173
- provenance: Map<string, ServerProvenance>,
174
- private callbacks: McpPanelCallbacks,
175
- tui: { requestRender(): void },
176
- private done: (result: McpPanelResult) => void,
177
- options: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings } = {},
178
- ) {
179
- this.tui = tui;
180
- this.noticeLines = options.noticeLines ?? [];
181
- this.authOnly = options.authOnly === true;
182
- this.keys = createPanelKeys(options.keybindings);
183
- this.prefix = config.settings?.toolPrefix ?? "server";
184
-
185
- for (const [serverName, definition] of Object.entries(config.mcpServers)) {
186
- if (this.authOnly && !callbacks.canAuthenticate(serverName)) continue;
187
- const prov = provenance.get(serverName);
188
- const serverCache = cache?.servers?.[serverName];
189
-
190
- const globalDirect = config.settings?.directTools;
191
- let toolFilter: true | string[] | false = false;
192
- if (definition.directTools !== undefined) {
193
- toolFilter = definition.directTools;
194
- } else if (globalDirect) {
195
- toolFilter = globalDirect;
196
- }
197
-
198
- const status = callbacks.getConnectionStatus(serverName);
199
- const failureMessage = callbacks.getFailureMessage?.(serverName) ?? null;
200
-
201
- const server: ServerState = {
202
- name: serverName,
203
- expanded: false,
204
- source: prov?.kind ?? "user",
205
- ...(prov?.importKind !== undefined ? { importKind: prov.importKind } : {}),
206
- definition,
207
- toolFilter,
208
- resourceCount: 0,
209
- connectionStatus: status,
210
- failureMessage,
211
- tools: [],
212
- hasCachedData: !!serverCache,
213
- };
214
- this.servers.push(server);
215
- if (serverCache && !this.authOnly) this.rebuildServerTools(server, serverCache);
216
- }
217
-
218
- this.rebuildVisibleItems();
219
- this.resetInactivityTimeout();
220
- }
221
-
222
- private resetInactivityTimeout(): void {
223
- if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
224
- this.inactivityTimeout = setTimeout(() => {
225
- this.cleanup();
226
- this.done({ cancelled: true, changes: new Map() });
227
- }, McpPanel.INACTIVITY_MS);
228
- }
229
-
230
- private cleanup(): void {
231
- if (this.inactivityTimeout) {
232
- clearTimeout(this.inactivityTimeout);
233
- this.inactivityTimeout = null;
234
- }
235
- }
236
-
237
- private rebuildVisibleItems(): void {
238
- const query = this.descSearchActive ? this.descQuery : this.nameQuery;
239
- const mode = this.descSearchActive ? "desc" : "name";
240
-
241
- this.visibleItems = [];
242
- for (let si = 0; si < this.servers.length; si++) {
243
- const server = this.servers[si];
244
- if (!server) continue;
245
- if (query && this.authOnly) {
246
- const score = mode === "name" ? fuzzyScore(query, server.name) : 0;
247
- if (score > 0) {
248
- this.visibleItems.push({ type: "server", serverIndex: si });
249
- }
250
- continue;
251
- }
252
-
253
- this.visibleItems.push({ type: "server", serverIndex: si });
254
- if (server.expanded || query) {
255
- for (let ti = 0; ti < server.tools.length; ti++) {
256
- const tool = server.tools[ti];
257
- if (!tool) continue;
258
- if (query) {
259
- const score = mode === "name"
260
- ? Math.max(
261
- fuzzyScore(query, tool.name),
262
- fuzzyScore(query, server.name) * 0.6,
263
- )
264
- : fuzzyScore(query, tool.description);
265
- if (score === 0) continue;
266
- }
267
- this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti });
268
- }
269
- }
270
- }
271
-
272
- if (query && !this.authOnly) {
273
- this.visibleItems = this.visibleItems.filter((item) => {
274
- if (item.type === "server") {
275
- return this.visibleItems.some(
276
- (other) => other.type === "tool" && other.serverIndex === item.serverIndex,
277
- );
278
- }
279
- return true;
280
- });
281
- }
282
- }
283
-
284
- private updateDirty(): void {
285
- this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect));
286
- }
287
-
288
- private buildResult(): McpPanelResult {
289
- const changes = new Map<string, true | string[] | false>();
290
- for (const server of this.servers) {
291
- const changed = server.tools.some((t) => t.isDirect !== t.wasDirect);
292
- if (!changed) continue;
293
- const directTools = server.tools.filter((t) => t.isDirect);
294
- if (directTools.length === server.tools.length && server.tools.length > 0) {
295
- changes.set(server.name, true);
296
- } else if (directTools.length === 0) {
297
- changes.set(server.name, false);
298
- } else {
299
- changes.set(server.name, directTools.map((t) => t.name));
300
- }
301
- }
302
- return { changes, cancelled: false };
303
- }
304
-
305
- handleInput(data: string): void {
306
- this.resetInactivityTimeout();
307
- this.importNotice = null;
308
- if (!this.authInFlight) this.authNotice = null;
309
-
310
- if (this.confirmingDiscard) {
311
- this.handleDiscardInput(data);
312
- return;
313
- }
314
-
315
- // Global shortcuts — always work, even during desc search
316
- if (matchesKey(data, "ctrl+c")) {
317
- this.cleanup();
318
- this.done({ cancelled: true, changes: new Map() });
319
- return;
320
- }
321
-
322
- if (matchesKey(data, "ctrl+s")) {
323
- this.cleanup();
324
- this.done(this.buildResult());
325
- return;
326
- }
327
-
328
- // Modal description search mode
329
- if (this.descSearchActive) {
330
- if (matchesKey(data, "escape") || this.keys.selectConfirm(data)) {
331
- this.descSearchActive = false;
332
- this.descQuery = "";
333
- this.rebuildVisibleItems();
334
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
335
- return;
336
- }
337
- if (matchesKey(data, "backspace")) {
338
- if (this.descQuery.length > 0) {
339
- this.descQuery = this.descQuery.slice(0, -1);
340
- this.rebuildVisibleItems();
341
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
342
- }
343
- return;
344
- }
345
- if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
346
- if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
347
- if (matchesKey(data, "space")) {
348
- // Toggle even while in desc search
349
- const item = this.visibleItems[this.cursorIndex];
350
- if (item) this.toggleItem(item);
351
- return;
352
- }
353
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
354
- this.descQuery += data;
355
- this.rebuildVisibleItems();
356
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
357
- return;
358
- }
359
- return;
360
- }
361
-
362
- if (matchesKey(data, "escape")) {
363
- if (this.nameQuery) {
364
- this.nameQuery = "";
365
- this.rebuildVisibleItems();
366
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
367
- return;
368
- }
369
- if (this.dirty) {
370
- this.confirmingDiscard = true;
371
- this.discardSelected = 1;
372
- return;
373
- }
374
- this.cleanup();
375
- this.done({ cancelled: true, changes: new Map() });
376
- return;
377
- }
378
-
379
- if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
380
- if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
381
-
382
- if (matchesKey(data, "space")) {
383
- const item = this.visibleItems[this.cursorIndex];
384
- if (item && !this.authOnly) this.toggleItem(item);
385
- return;
386
- }
387
-
388
- if (this.keys.selectConfirm(data)) {
389
- const item = this.visibleItems[this.cursorIndex];
390
- if (!item) return;
391
- const server = this.servers[item.serverIndex];
392
- if (!server) return;
393
- if (item.type === "server") {
394
- if (server.connectionStatus === "disabled") return;
395
- if (this.authOnly || server.connectionStatus === "needs-auth") {
396
- this.authenticateServer(server);
397
- return;
398
- }
399
- server.expanded = !server.expanded;
400
- this.rebuildVisibleItems();
401
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
402
- } else if (item.toolIndex !== undefined) {
403
- const tool = server.tools[item.toolIndex];
404
- if (!tool) return;
405
- tool.isDirect = !tool.isDirect;
406
- if (tool.isDirect && server.source === "import") {
407
- this.importNotice = "Only tool selections save to Pi config.";
408
- }
409
- this.updateDirty();
410
- }
411
- return;
412
- }
413
-
414
- if (matchesKey(data, "ctrl+a")) {
415
- const item = this.visibleItems[this.cursorIndex];
416
- if (item) this.authenticateSelectedServer(item);
417
- return;
418
- }
419
-
420
- if (matchesKey(data, "ctrl+r")) {
421
- const item = this.visibleItems[this.cursorIndex];
422
- if (!item) return;
423
- const server = this.servers[item.serverIndex];
424
- if (server) this.reconnectServer(server);
425
- return;
426
- }
427
-
428
- if (matchesKey(data, "ctrl+y")) {
429
- const item = this.visibleItems[this.cursorIndex];
430
- if (!item) return;
431
- const server = this.servers[item.serverIndex];
432
- if (!server || server.connectionStatus !== "failed" || !server.failureMessage) return;
433
- const serverName = sanitizeDisplayText(server.name);
434
- const failureMessage = sanitizeDisplayText(server.failureMessage);
435
- copyToClipboard(failureMessage).then(() => {
436
- this.authNotice = `Copied error for ${serverName} to clipboard`;
437
- this.tui.requestRender();
438
- }).catch((error) => {
439
- const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
440
- this.authNotice = `Failed to copy error for ${serverName}: ${message}`;
441
- this.tui.requestRender();
442
- });
443
- return;
444
- }
445
-
446
- if (data === "?") {
447
- if (this.authOnly) return;
448
- this.descSearchActive = true;
449
- this.descQuery = "";
450
- this.rebuildVisibleItems();
451
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
452
- return;
453
- }
454
-
455
- // Backspace removes from name query
456
- if (matchesKey(data, "backspace")) {
457
- if (this.nameQuery.length > 0) {
458
- this.nameQuery = this.nameQuery.slice(0, -1);
459
- this.rebuildVisibleItems();
460
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
461
- }
462
- return;
463
- }
464
-
465
- // All other printable chars → always-on name search
466
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
467
- this.nameQuery += data;
468
- this.rebuildVisibleItems();
469
- this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
470
- return;
471
- }
472
- }
473
-
474
- private authenticateSelectedServer(item: VisibleItem): void {
475
- const server = this.servers[item.serverIndex];
476
- if (server) this.authenticateServer(server);
477
- }
478
-
479
- private authenticateServer(server: ServerState): void {
480
- if (this.authInFlight) return;
481
- if (server.connectionStatus === "connecting" || server.connectionStatus === "disabled") return;
482
- const serverName = sanitizeDisplayText(server.name);
483
- if (!this.callbacks.canAuthenticate(server.name)) {
484
- this.authNotice = `${serverName} does not use OAuth authentication.`;
485
- return;
486
- }
487
-
488
- this.authInFlight = server.name;
489
- this.authNotice = `Authenticating ${serverName}...`;
490
- this.tui.requestRender();
491
-
492
- this.callbacks.authenticate(server.name).then((result) => {
493
- server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
494
- if (result.ok) {
495
- this.authNotice = `OAuth finished for ${serverName}. Reconnecting...`;
496
- this.authInFlight = null;
497
- this.tui.requestRender();
498
- this.reconnectServer(server, { afterAuth: true });
499
- return;
500
- }
501
-
502
- const message = sanitizeDisplayText(result.message);
503
- this.authNotice = `OAuth failed for ${serverName}${message ? `: ${message}` : ". Check the notification for details."}`;
504
- this.authInFlight = null;
505
- this.tui.requestRender();
506
- }).catch((error) => {
507
- const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
508
- server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
509
- this.authNotice = `OAuth failed for ${serverName}: ${message}`;
510
- this.authInFlight = null;
511
- this.tui.requestRender();
512
- });
513
- }
514
-
515
- private reconnectServer(server: ServerState, options: { afterAuth?: boolean } = {}): void {
516
- if (server.connectionStatus === "connecting" || server.connectionStatus === "disabled") return;
517
- const serverName = sanitizeDisplayText(server.name);
518
- server.connectionStatus = "connecting";
519
- this.tui.requestRender();
520
-
521
- this.callbacks.reconnect(server.name, options.afterAuth).then((connected) => {
522
- server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
523
- server.failureMessage = this.callbacks.getFailureMessage?.(server.name) ?? null;
524
- if (server.connectionStatus === "connected") {
525
- const entry = this.callbacks.refreshCacheAfterReconnect(server.name);
526
- if (entry) {
527
- this.rebuildServerTools(server, entry);
528
- }
529
- }
530
- if (options.afterAuth) {
531
- this.authNotice = connected && server.connectionStatus === "connected"
532
- ? `OAuth finished for ${serverName}. Reconnected.`
533
- : `OAuth finished for ${serverName}, but reconnect did not complete. Press ctrl+r to retry.`;
534
- }
535
- this.tui.requestRender();
536
- }).catch((error) => {
537
- server.connectionStatus = "failed";
538
- const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
539
- this.authNotice = `Reconnect failed for ${serverName}: ${message}`;
540
- this.tui.requestRender();
541
- });
542
- }
543
-
544
- private toggleItem(item: VisibleItem): void {
545
- if (this.authOnly) return;
546
- const server = this.servers[item.serverIndex];
547
- if (!server) return;
548
- if (item.type === "server") {
549
- const newState = !server.tools.every((t) => t.isDirect);
550
- if (server.source === "import" && newState) {
551
- this.importNotice = "Only tool selections save to Pi config.";
552
- }
553
- for (const t of server.tools) t.isDirect = newState;
554
- } else if (item.toolIndex !== undefined) {
555
- const tool = server.tools[item.toolIndex];
556
- if (!tool) return;
557
- tool.isDirect = !tool.isDirect;
558
- if (tool.isDirect && server.source === "import") {
559
- this.importNotice = "Only tool selections save to Pi config.";
560
- }
561
- }
562
- this.updateDirty();
563
- }
564
-
565
- private handleDiscardInput(data: string): void {
566
- if (matchesKey(data, "ctrl+c")) {
567
- this.cleanup();
568
- this.done({ cancelled: true, changes: new Map() });
569
- return;
570
- }
571
- if (matchesKey(data, "escape") || data === "n" || data === "N") {
572
- this.confirmingDiscard = false;
573
- return;
574
- }
575
- if (this.keys.selectConfirm(data)) {
576
- this.cleanup();
577
- if (this.discardSelected === 0) {
578
- this.done({ cancelled: true, changes: new Map() });
579
- } else {
580
- this.done(this.buildResult());
581
- }
582
- return;
583
- }
584
- if (data === "y" || data === "Y") {
585
- this.cleanup();
586
- this.done({ cancelled: true, changes: new Map() });
587
- return;
588
- }
589
- if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) {
590
- this.discardSelected = this.discardSelected === 0 ? 1 : 0;
591
- }
592
- }
593
-
594
- private moveCursor(delta: number): void {
595
- if (this.visibleItems.length === 0) return;
596
- this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta));
597
- }
598
-
599
- private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void {
600
- const existingState = new Map(server.tools.map(tool => [tool.name, tool]));
601
- const metadata = reconstructToolMetadata(server.name, entry, this.prefix, server.definition);
602
- server.resourceCount = metadata.filter(tool => tool.resourceUri !== undefined).length;
603
- server.tools = metadata.filter(tool => tool.resourceUri === undefined).map(tool => {
604
- const previous = existingState.get(tool.originalName);
605
- const pinned = server.toolFilter === true
606
- || (Array.isArray(server.toolFilter) && server.toolFilter.includes(tool.originalName));
607
- return {
608
- name: tool.originalName,
609
- description: tool.description,
610
- isDirect: previous?.isDirect ?? pinned,
611
- wasDirect: previous?.wasDirect ?? pinned,
612
- estimatedTokens: estimateTokens(tool),
613
- };
614
- });
615
- server.hasCachedData = true;
616
- this.rebuildVisibleItems();
617
- this.updateDirty();
618
- }
619
-
620
- render(width: number): string[] {
621
- const innerW = width - 2;
622
- const lines: string[] = [];
623
- const t = this.t;
624
- const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
625
- const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
626
- const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
627
-
628
- const row = (content: string) =>
629
- fg(t.border, "│") + truncateToWidth(" " + sanitizeRowContent(content), innerW, "…", true) + fg(t.border, "│");
630
- const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
631
- const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
632
-
633
- const titleText = this.authOnly ? " MCP OAuth " : " MCP Servers ";
634
- const borderLen = innerW - visibleWidth(titleText);
635
- const leftB = Math.floor(borderLen / 2);
636
- const rightB = borderLen - leftB;
637
- lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
638
-
639
- lines.push(emptyRow());
640
-
641
- const cursor = fg(t.selected, "│");
642
- const searchIcon = fg(t.border, "◎");
643
- if (this.descSearchActive) {
644
- lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`));
645
- } else if (this.nameQuery) {
646
- lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`));
647
- } else {
648
- lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
649
- }
650
-
651
- lines.push(emptyRow());
652
- if (this.noticeLines.length > 0) {
653
- for (const notice of this.noticeLines) {
654
- lines.push(row(fg(t.hint, italic(sanitizeDisplayText(notice)))));
655
- }
656
- lines.push(emptyRow());
657
- }
658
- lines.push(divider());
659
-
660
- if (this.servers.length === 0) {
661
- lines.push(emptyRow());
662
- lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
663
- lines.push(emptyRow());
664
- } else {
665
- const maxVis = McpPanel.MAX_VISIBLE;
666
- const total = this.visibleItems.length;
667
- const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
668
- const endIdx = Math.min(startIdx + maxVis, total);
669
-
670
- lines.push(emptyRow());
671
-
672
- for (let i = startIdx; i < endIdx; i++) {
673
- const item = this.visibleItems[i];
674
- if (!item) continue;
675
- const isCursor = i === this.cursorIndex;
676
- const server = this.servers[item.serverIndex];
677
- if (!server) continue;
678
-
679
- if (item.type === "server") {
680
- lines.push(row(this.renderServerRow(server, isCursor)));
681
- if (isCursor && !this.authOnly && server.resourceCount > 0) {
682
- lines.push(row(fg(t.hint, " Resources: mcp actions resources / read-resource")));
683
- }
684
- if (isCursor && server.connectionStatus === "failed" && server.failureMessage) {
685
- for (const line of this.wrapText(sanitizeDisplayText(server.failureMessage), innerW - 6)) {
686
- lines.push(row(` ${fg(t.cancel, line)}`));
687
- }
688
- }
689
- } else if (item.toolIndex !== undefined) {
690
- const tool = server.tools[item.toolIndex];
691
- if (tool) lines.push(row(this.renderToolRow(tool, isCursor, innerW)));
692
- }
693
- }
694
-
695
- lines.push(emptyRow());
696
-
697
- if (total > maxVis) {
698
- const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
699
- lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
700
- lines.push(emptyRow());
701
- }
702
-
703
- if (this.importNotice) {
704
- lines.push(row(fg(t.needsAuth, italic(sanitizeDisplayText(this.importNotice)))));
705
- lines.push(emptyRow());
706
- }
707
- if (this.authNotice) {
708
- lines.push(row(fg(t.needsAuth, italic(sanitizeDisplayText(this.authNotice)))));
709
- lines.push(emptyRow());
710
- }
711
- }
712
-
713
- lines.push(divider());
714
- lines.push(emptyRow());
715
-
716
- if (this.confirmingDiscard) {
717
- const discardBtn = this.discardSelected === 0
718
- ? inverse(bold(fg(t.cancel, " Discard ")))
719
- : fg(t.hint, " Discard ");
720
- const keepBtn = this.discardSelected === 1
721
- ? inverse(bold(fg(t.confirm, " Keep & Close ")))
722
- : fg(t.hint, " Keep & Close ");
723
- lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`));
724
- } else {
725
- if (this.authOnly) {
726
- lines.push(row(fg(t.description, "select a server to authenticate")));
727
- } else {
728
- const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0);
729
- const totalTokens = this.servers.reduce(
730
- (sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0),
731
- 0,
732
- );
733
- const stats =
734
- directCount > 0 ? `${directCount} pinned ~${totalTokens.toLocaleString()} tokens` : "no pinned tools";
735
- lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : ""))));
736
- lines.push(row(fg(t.hint, "Pins load at startup; discover more with mcp_search.")));
737
- }
738
- }
739
-
740
- lines.push(emptyRow());
741
- const hints = this.authOnly
742
- ? [
743
- italic("↑↓") + " navigate",
744
- italic("⏎") + " auth",
745
- italic("ctrl+a") + " auth",
746
- italic("esc") + " clear/close",
747
- italic("ctrl+c") + " quit",
748
- ]
749
- : [
750
- italic("↑↓") + " navigate",
751
- italic("space") + " pin/unpin",
752
- italic("⏎") + " expand/auth",
753
- italic("ctrl+a") + " auth",
754
- italic("ctrl+r") + " reconnect",
755
- ...(this.selectedServerHasFailureMessage() ? [italic("ctrl+y") + " copy error"] : []),
756
- italic("?") + " desc search",
757
- italic("ctrl+s") + " save",
758
- italic("esc") + " clear/close",
759
- italic("ctrl+c") + " quit",
760
- ];
761
- const gap = " ";
762
- const gapW = 2;
763
- const maxW = innerW - 2;
764
- let curLine = "";
765
- let curW = 0;
766
- for (const hint of hints) {
767
- const hw = visibleWidth(hint);
768
- const needed = curW === 0 ? hw : gapW + hw;
769
- if (curW > 0 && curW + needed > maxW) {
770
- lines.push(row(fg(t.hint, curLine)));
771
- curLine = hint;
772
- curW = hw;
773
- } else {
774
- curLine += (curW > 0 ? gap : "") + hint;
775
- curW += needed;
776
- }
777
- }
778
- if (curLine) lines.push(row(fg(t.hint, curLine)));
779
-
780
- lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
781
-
782
- return lines;
783
- }
784
-
785
- private renderServerRow(server: ServerState, isCursor: boolean): string {
786
- const t = this.t;
787
- const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
788
-
789
- const expandIcon = server.expanded ? "▾" : "▸";
790
- const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·");
791
-
792
- const serverName = sanitizeDisplayText(server.name);
793
- const importKind = sanitizeDisplayText(server.importKind ?? "import");
794
- const nameStr = isCursor ? bold(fg(t.selected, serverName)) : serverName;
795
- const importLabel = server.source === "import" ? fg(t.description, ` (${importKind})`) : "";
796
- const statusLabel = this.renderConnectionStatus(server);
797
-
798
- if (!server.hasCachedData && !this.authOnly) {
799
- return `${prefix} ${nameStr}${importLabel}${statusLabel} ${fg(t.description, "(undiscovered)")}`;
800
- }
801
-
802
- const directCount = server.tools.filter((t) => t.isDirect).length;
803
- const totalCount = server.tools.length;
804
- let toggleIcon = fg(t.description, "○");
805
- if (directCount === totalCount && totalCount > 0) {
806
- toggleIcon = fg(t.direct, "●");
807
- } else if (directCount > 0) {
808
- toggleIcon = fg(t.needsAuth, "◐");
809
- }
810
-
811
- const toolInfo = this.authOnly ? "" : fg(t.description,
812
- `${totalCount} tool${totalCount === 1 ? "" : "s"}, ${directCount} pinned${server.resourceCount > 0 ? `, ${server.resourceCount} resource${server.resourceCount === 1 ? "" : "s"}` : ""}`);
813
-
814
- return `${prefix} ${toggleIcon} ${nameStr}${importLabel}${statusLabel} ${toolInfo}`;
815
- }
816
-
817
- private selectedServerHasFailureMessage(): boolean {
818
- const item = this.visibleItems[this.cursorIndex];
819
- if (!item) return false;
820
- const server = this.servers[item.serverIndex];
821
- return server?.connectionStatus === "failed" && !!server.failureMessage;
822
- }
823
-
824
- private wrapText(text: string, width: number): string[] {
825
- const max = Math.max(8, width);
826
- const words = text.split(/\s+/).filter(Boolean);
827
- const lines: string[] = [];
828
- let current = "";
829
- const splitLongWord = (word: string): string => {
830
- let rest = word;
831
- while (visibleWidth(rest) > max) {
832
- let take = "";
833
- let index = 0;
834
- while (index < rest.length && visibleWidth(take + rest.charAt(index)) <= max) {
835
- take += rest.charAt(index);
836
- index++;
837
- }
838
- if (!take) take = rest.charAt(0);
839
- lines.push(take);
840
- rest = rest.slice(take.length);
841
- }
842
- return rest;
843
- };
844
-
845
- for (const word of words) {
846
- const candidate = current ? `${current} ${word}` : word;
847
- if (visibleWidth(candidate) <= max) {
848
- current = candidate;
849
- } else {
850
- if (current) lines.push(current);
851
- current = splitLongWord(word);
852
- }
853
- }
854
- if (current) lines.push(current);
855
- return lines.length > 0 ? lines : [text];
856
- }
857
-
858
- private renderConnectionStatus(server: ServerState): string {
859
- const t = this.t;
860
- if (this.authInFlight === server.name) return ` ${fg(t.needsAuth, "authenticating")}`;
861
- if (server.connectionStatus === "disabled") return ` ${fg(t.description, "disabled")}`;
862
- if (server.connectionStatus === "needs-auth") return ` ${fg(t.needsAuth, "needs auth")}`;
863
- if (server.connectionStatus === "connecting") return ` ${fg(t.needsAuth, "connecting")}`;
864
- if (server.connectionStatus === "failed") return ` ${fg(t.cancel, "failed")}`;
865
- if (this.authOnly && server.connectionStatus === "connected") return ` ${fg(t.direct, "connected")}`;
866
- if (this.authOnly) return ` ${fg(t.description, "idle")}`;
867
- return "";
868
- }
869
-
870
- private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string {
871
- const t = this.t;
872
- const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
873
-
874
- const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○");
875
- const cursor = isCursor ? fg(t.selected, "▸") : " ";
876
- const toolName = sanitizeDisplayText(tool.name);
877
- const description = sanitizeDisplayText(tool.description);
878
- const nameStr = isCursor ? bold(fg(t.selected, toolName)) : toolName;
879
-
880
- const prefixLen = 7 + visibleWidth(toolName);
881
- const maxDescLen = Math.max(0, innerW - prefixLen - 8);
882
- const descStr =
883
- maxDescLen > 5 && description
884
- ? fg(t.description, "— " + truncateToWidth(description, maxDescLen, "…"))
885
- : "";
886
-
887
- return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}`;
888
- }
889
-
890
- invalidate(): void {}
891
-
892
- dispose(): void {
893
- this.cleanup();
894
- }
895
- }
896
-
897
- export function createMcpPanel(
898
- config: McpConfig,
899
- cache: MetadataCache | null,
900
- provenance: Map<string, ServerProvenance>,
901
- callbacks: McpPanelCallbacks,
902
- tui: { requestRender(): void },
903
- done: (result: McpPanelResult) => void,
904
- options?: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings },
905
- ): McpPanel & { dispose(): void } {
906
- return new McpPanel(config, cache, provenance, callbacks, tui, done, options ?? {});
907
- }