@pi-archimedes/mcp 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
package/src/panel.ts
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/mcp panel` management panel (plan-027, Task 3).
|
|
3
|
+
*
|
|
4
|
+
* Mode-based `string[]` overlay component built on the shared overlay chrome
|
|
5
|
+
* (`@pi-archimedes/core/overlay`) — structurally identical to the `/agents`
|
|
6
|
+
* manager (`packages/subagent/src/agent-manager.ts`): `renderHeader` at top,
|
|
7
|
+
* a cursor-highlighted flat list of expandable server→tools rows, bracket-
|
|
8
|
+
* hinted `renderFooter` lines at the bottom, everything wrapped by
|
|
9
|
+
* `wrapWithBorder` (ADR 0003). No pi-tui `SelectList`/`DynamicBorder`/
|
|
10
|
+
* `Input`/`Focusable`.
|
|
11
|
+
*
|
|
12
|
+
* States and keys (filter = self-managed string, agent-manager style):
|
|
13
|
+
* - flat list of servers; `enter` expands/collapses (EXCEPT on a needs-auth
|
|
14
|
+
* server: `enter` runs the in-panel OAuth flow — ADR 0005)
|
|
15
|
+
* - `space` toggles the direct-tools selection (server row → all of its
|
|
16
|
+
* tools as a group; tool row → the single tool); dirty servers tracked via
|
|
17
|
+
* `changedServers` (any tool with isDirect !== wasDirect) for the save
|
|
18
|
+
* - `e` enables/disables (writeServerDisabled + client teardown on disable;
|
|
19
|
+
* identical semantics to `/mcp enable|disable`), `l` logs out
|
|
20
|
+
* (mcpLogoutServer), `r` reconnects + records the settled outcome
|
|
21
|
+
* (ADR 0004), `a` authenticates (in-panel, same flow as `enter`)
|
|
22
|
+
* - `/` begins filter mode: printable chars (incl. space) append, backspace
|
|
23
|
+
* edits; while the filter is non-empty, e/l/a/r are INERT (so their
|
|
24
|
+
* letters can be typed into the query) and `esc`/`ctrl+c` still close
|
|
25
|
+
* - `ctrl+s` writes `directTools` for each changed server
|
|
26
|
+
* (writeServerDirectTools), notifies "/reload to apply", closes
|
|
27
|
+
* - `esc`/`ctrl+c` outside `authing`: close, discard (unsaved toggles are
|
|
28
|
+
* lost, by design — no confirm)
|
|
29
|
+
*
|
|
30
|
+
* Status resolution (ADR 0004): a LIVE client with a verified status
|
|
31
|
+
* (connected / needs-auth / error) wins; else the persisted outcome from
|
|
32
|
+
* `loadMetadataCache().serverStatuses` (with the staleness suffix from its
|
|
33
|
+
* `at` timestamp); else "cached". `def.disabled === true` always renders
|
|
34
|
+
* "disabled". Every in-panel settle point (auth reconnect, manual
|
|
35
|
+
* reconnect) records the outcome via `recordClientOutcome` (→ the single
|
|
36
|
+
* `recordServerOutcome` recorder).
|
|
37
|
+
*
|
|
38
|
+
* In-panel auth (ADR 0005): the panel does NOT reuse `runAuthWithLoader`
|
|
39
|
+
* (the command-path BorderedLoader presentation). It enters an `authing`
|
|
40
|
+
* substate rendered as a transient notice line; only the UX-neutral shared
|
|
41
|
+
* plumbing is reused: `ServerClient.authenticate` (single flow entry point),
|
|
42
|
+
* `openAuthUrl`, `reconnectAfterAuth`/`AuthRunOutcome`. `esc` aborts via the
|
|
43
|
+
* flow's AbortController (surfaces as a CANCELLATION, not an error);
|
|
44
|
+
* `ctrl+c` aborts AND closes the panel; every other key is ignored until
|
|
45
|
+
* the flow settles.
|
|
46
|
+
*/
|
|
47
|
+
import { CURSOR_MARKER, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
48
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
49
|
+
import type {
|
|
50
|
+
ExtensionAPI,
|
|
51
|
+
ExtensionCommandContext,
|
|
52
|
+
Theme,
|
|
53
|
+
} from "@earendil-works/pi-coding-agent";
|
|
54
|
+
import {
|
|
55
|
+
OVERLAY_CHROME,
|
|
56
|
+
borderContentWidth,
|
|
57
|
+
hardTruncate,
|
|
58
|
+
padEnd,
|
|
59
|
+
renderFooter,
|
|
60
|
+
renderHeader,
|
|
61
|
+
visibleWidth,
|
|
62
|
+
wrapWithBorder,
|
|
63
|
+
} from "@pi-archimedes/core/overlay";
|
|
64
|
+
import { extractOAuthConfig } from "./auth-flow.js";
|
|
65
|
+
import { openAuthUrl, reconnectAfterAuth, type AuthRunOutcome } from "./auth-run.js";
|
|
66
|
+
import { mcpLogoutServer } from "./commands-auth.js";
|
|
67
|
+
import { isHttpDef, loadMcpConfig } from "./config.js";
|
|
68
|
+
import { writeServerDisabled, writeServerDirectTools } from "./config-write.js";
|
|
69
|
+
import { loadMetadataCache, recordClientOutcome } from "./metadata-cache.js";
|
|
70
|
+
import {
|
|
71
|
+
ageSuffix,
|
|
72
|
+
buildRow,
|
|
73
|
+
buildVisibleRows,
|
|
74
|
+
computeSelection,
|
|
75
|
+
filterRows,
|
|
76
|
+
toggleTool,
|
|
77
|
+
type McpPanelDeps,
|
|
78
|
+
type RowSources,
|
|
79
|
+
type ServerRow,
|
|
80
|
+
type ServerRowStatus,
|
|
81
|
+
type ToolRow,
|
|
82
|
+
type VisibleRow,
|
|
83
|
+
} from "./panel-rows.js";
|
|
84
|
+
|
|
85
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface McpPanelState {
|
|
88
|
+
servers: ServerRow[];
|
|
89
|
+
cursor: number;
|
|
90
|
+
filter: string;
|
|
91
|
+
filterMode: boolean;
|
|
92
|
+
/** In-panel OAuth in flight (ADR 0005) — every key but esc/ctrl+c is inert. */
|
|
93
|
+
authing: { serverName: string } | null;
|
|
94
|
+
/** Servers with any tool where isDirect !== wasDirect (the ctrl+s dirty set). */
|
|
95
|
+
changedServers: Set<string>;
|
|
96
|
+
/** Transient result line rendered above the footer. */
|
|
97
|
+
notice: { text: string; tone: "info" | "success" | "error" } | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface McpPanelComponent {
|
|
101
|
+
render(width: number): string[];
|
|
102
|
+
handleInput(data: string): void;
|
|
103
|
+
invalidate(): void;
|
|
104
|
+
dispose(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const HINTS_FIRST =
|
|
108
|
+
" [↑/↓] move [space] toggle [enter] expand/auth [a] auth [e] en/dis ";
|
|
109
|
+
const HINTS_SECOND =
|
|
110
|
+
"[l] logout [r] reconnect [/] search [ctrl+s] save [esc] close ";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the overlay component. `ctx` is captured for `ctx.cwd` (config
|
|
114
|
+
* write-back) and `ctx.ui.notify` (command-path parity toasts); everything
|
|
115
|
+
* else is a direct import or an injected dep.
|
|
116
|
+
*/
|
|
117
|
+
function makeMcpPanel(
|
|
118
|
+
servers: ServerRow[],
|
|
119
|
+
tui: TUI,
|
|
120
|
+
theme: Theme,
|
|
121
|
+
done: () => void,
|
|
122
|
+
deps: McpPanelDeps,
|
|
123
|
+
ctx: ExtensionCommandContext,
|
|
124
|
+
): McpPanelComponent {
|
|
125
|
+
const state: McpPanelState = {
|
|
126
|
+
servers,
|
|
127
|
+
cursor: 0,
|
|
128
|
+
filter: "",
|
|
129
|
+
filterMode: false,
|
|
130
|
+
authing: null,
|
|
131
|
+
changedServers: new Set(),
|
|
132
|
+
notice: null,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let authController: AbortController | null = null;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
let cachedWidth: number | undefined;
|
|
138
|
+
let cachedLines: string[] | undefined;
|
|
139
|
+
|
|
140
|
+
function requestRender(): void {
|
|
141
|
+
cachedWidth = undefined;
|
|
142
|
+
cachedLines = undefined;
|
|
143
|
+
if (!disposed) tui.requestRender();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setNotice(text: string, tone: "info" | "success" | "error"): void {
|
|
147
|
+
state.notice = { text, tone };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Recompute the ctrl+s dirty set from isDirect vs wasDirect. */
|
|
151
|
+
function syncChanged(): void {
|
|
152
|
+
state.changedServers = new Set(
|
|
153
|
+
state.servers
|
|
154
|
+
.filter((s) => s.tools.some((t) => t.isDirect !== t.wasDirect))
|
|
155
|
+
.map((s) => s.name),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rowByName(name: string): ServerRow | undefined {
|
|
160
|
+
return state.servers.find((s) => s.name === name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Visible rows for cursor/render. Unfiltered → buildVisibleRows. With a
|
|
165
|
+
* filter → the matching servers auto-interleave only their matching tool
|
|
166
|
+
* rows (a server-name hit keeps ALL of its tools).
|
|
167
|
+
*/
|
|
168
|
+
function visibleRows(): VisibleRow[] {
|
|
169
|
+
const filtered = filterRows(state.servers, state.filter);
|
|
170
|
+
if (state.filter.length === 0) return buildVisibleRows(filtered);
|
|
171
|
+
const q = state.filter.toLowerCase();
|
|
172
|
+
const rows: VisibleRow[] = [];
|
|
173
|
+
for (const s of filtered) {
|
|
174
|
+
rows.push({ kind: "server", server: s });
|
|
175
|
+
const nameHit = s.name.toLowerCase().includes(q);
|
|
176
|
+
for (const t of s.tools) {
|
|
177
|
+
if (nameHit || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)) {
|
|
178
|
+
rows.push({ kind: "tool", server: s, tool: t });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return rows;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Keep the cursor inside the (possibly shorter) visible list. */
|
|
186
|
+
function clampCursor(): void {
|
|
187
|
+
const max = Math.max(0, visibleRows().length - 1);
|
|
188
|
+
if (state.cursor > max) state.cursor = max;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rowSources(): RowSources {
|
|
192
|
+
return {
|
|
193
|
+
globalConfig: loadMcpConfig(),
|
|
194
|
+
outcomes: loadMetadataCache().serverStatuses ?? {},
|
|
195
|
+
manager: deps.getManager(),
|
|
196
|
+
getCachedTools: deps.getCachedTools,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Re-seed one server's status + tool list from the live manager and cache,
|
|
202
|
+
* preserving the expansion and any isDirect overrides the user made
|
|
203
|
+
* (wasDirect re-seeds identically from config, so the dirty set stays
|
|
204
|
+
* meaningful across a refresh).
|
|
205
|
+
*/
|
|
206
|
+
function refreshRow(name: string): void {
|
|
207
|
+
const def = deps.getServerDefs()[name];
|
|
208
|
+
const existing = rowByName(name);
|
|
209
|
+
if (def === undefined || existing === undefined) return;
|
|
210
|
+
const fresh = buildRow(name, def, rowSources());
|
|
211
|
+
for (const t of fresh.tools) {
|
|
212
|
+
const prev = existing.tools.find((p) => p.name === t.name);
|
|
213
|
+
if (prev) t.isDirect = prev.isDirect;
|
|
214
|
+
}
|
|
215
|
+
existing.status = fresh.status;
|
|
216
|
+
existing.tools = fresh.tools;
|
|
217
|
+
existing.hasCachedData = fresh.hasCachedData;
|
|
218
|
+
if (fresh.statusAt !== undefined) existing.statusAt = fresh.statusAt;
|
|
219
|
+
else delete existing.statusAt;
|
|
220
|
+
if (fresh.failureMessage !== undefined) existing.failureMessage = fresh.failureMessage;
|
|
221
|
+
else delete existing.failureMessage;
|
|
222
|
+
syncChanged();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function closePanel(): void {
|
|
226
|
+
disposed = true;
|
|
227
|
+
done();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Actions ────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function toggleEnabled(name: string): void {
|
|
233
|
+
const def = deps.getServerDefs()[name];
|
|
234
|
+
if (def === undefined) {
|
|
235
|
+
setNotice(`Unknown server: ${name}`, "error");
|
|
236
|
+
requestRender();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const disable = def.disabled !== true;
|
|
240
|
+
try {
|
|
241
|
+
// Identical semantics to /mcp enable|disable: single-field write-back
|
|
242
|
+
// to <cwd>/<CONFIG_DIR_NAME>/mcp.json (ADR 0002) + tearing down the
|
|
243
|
+
// live client on disable; nothing applies until /reload.
|
|
244
|
+
writeServerDisabled(ctx.cwd, name, disable);
|
|
245
|
+
if (disable) deps.getManager().getClient(name)?.close();
|
|
246
|
+
refreshRow(name);
|
|
247
|
+
const message = `✓ ${name} ${disable ? "disabled" : "enabled"} — run /reload to apply`;
|
|
248
|
+
setNotice(message, "info");
|
|
249
|
+
ctx.ui.notify(message, "info");
|
|
250
|
+
} catch (e) {
|
|
251
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
252
|
+
setNotice(message, "error");
|
|
253
|
+
ctx.ui.notify(`/mcp panel: ${message}`, "error");
|
|
254
|
+
}
|
|
255
|
+
requestRender();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function logout(name: string): void {
|
|
259
|
+
const result = mcpLogoutServer(name, deps.getManager);
|
|
260
|
+
if (!result.ok) {
|
|
261
|
+
const message = `Could not log out of ${name}: ${result.error ?? "unknown error"}`;
|
|
262
|
+
setNotice(message, "error");
|
|
263
|
+
ctx.ui.notify(message, "error");
|
|
264
|
+
requestRender();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
refreshRow(name);
|
|
268
|
+
// The persisted needs-auth outcome (ADR 0004) described the token that
|
|
269
|
+
// was JUST deleted — it is unverified until the next settle, so demote
|
|
270
|
+
// the row to "cached" rather than claiming a stale 401.
|
|
271
|
+
const s = rowByName(name);
|
|
272
|
+
if (s?.status === "needs-auth") {
|
|
273
|
+
s.status = "cached";
|
|
274
|
+
delete s.statusAt;
|
|
275
|
+
delete s.failureMessage;
|
|
276
|
+
}
|
|
277
|
+
setNotice(`Logged out of ${name}`, "info");
|
|
278
|
+
requestRender();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function reconnect(name: string): void {
|
|
282
|
+
const def = deps.getServerDefs()[name];
|
|
283
|
+
if (def?.disabled === true) {
|
|
284
|
+
setNotice(`${name} is disabled — press [e] to enable, then /reload`, "error");
|
|
285
|
+
requestRender();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const client = deps.getManager().getClient(name);
|
|
289
|
+
if (!client) {
|
|
290
|
+
setNotice(`${name}: no managed connection — run /reload to pick up config changes`, "error");
|
|
291
|
+
requestRender();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
setNotice(`Reconnecting ${name}…`, "info");
|
|
295
|
+
requestRender();
|
|
296
|
+
void (async () => {
|
|
297
|
+
await client.close();
|
|
298
|
+
try {
|
|
299
|
+
await client.connect();
|
|
300
|
+
} catch {
|
|
301
|
+
// A failed connect settles the client into "error" (client.error);
|
|
302
|
+
// the refresh below renders it.
|
|
303
|
+
}
|
|
304
|
+
// ADR 0004: persist the settled outcome at this settle point.
|
|
305
|
+
recordClientOutcome(client);
|
|
306
|
+
if (disposed) return;
|
|
307
|
+
refreshRow(name);
|
|
308
|
+
const s = rowByName(name);
|
|
309
|
+
if (s?.status === "connected") {
|
|
310
|
+
setNotice(`✓ ${name} connected (${s.tools.length} tools)`, "success");
|
|
311
|
+
} else if (s?.status === "needs-auth") {
|
|
312
|
+
setNotice(`⚠ ${name} needs auth — press [a] to authenticate`, "info");
|
|
313
|
+
} else {
|
|
314
|
+
setNotice(`✗ ${name} error — ${s?.failureMessage ?? "connect failed"}`, "error");
|
|
315
|
+
}
|
|
316
|
+
requestRender();
|
|
317
|
+
})();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* In-panel OAuth (ADR 0005). Enters the `authing` substate and runs the
|
|
322
|
+
* flow off-thread; the settle point refreshes the row, records the
|
|
323
|
+
* outcome, and renders a transient result line.
|
|
324
|
+
*/
|
|
325
|
+
function startAuth(name: string): void {
|
|
326
|
+
if (state.authing !== null) return; // one flow at a time (same client)
|
|
327
|
+
const def = deps.getServerDefs()[name];
|
|
328
|
+
if (def === undefined) {
|
|
329
|
+
setNotice(`Unknown server: ${name}`, "error");
|
|
330
|
+
requestRender();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Single OAuth-eligibility check (parity with /mcp auth + the adapter's
|
|
334
|
+
// canAuthenticate): an http def with a resolvable OAuth config.
|
|
335
|
+
if (!isHttpDef(def) || extractOAuthConfig(def.auth) === null) {
|
|
336
|
+
setNotice(
|
|
337
|
+
`${name} is not configured for OAuth — set auth: "oauth" (or an OAuth config) in mcp.json`,
|
|
338
|
+
"error",
|
|
339
|
+
);
|
|
340
|
+
requestRender();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const client = deps.getManager().getClient(name);
|
|
344
|
+
if (!client) {
|
|
345
|
+
setNotice(`${name} is not managed yet — start a new session and try again`, "error");
|
|
346
|
+
requestRender();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
state.authing = { serverName: name };
|
|
350
|
+
setNotice(`Authenticating ${name}… (esc to cancel)`, "info");
|
|
351
|
+
requestRender();
|
|
352
|
+
|
|
353
|
+
const controller = new AbortController();
|
|
354
|
+
authController = controller;
|
|
355
|
+
void (async () => {
|
|
356
|
+
let outcome: AuthRunOutcome;
|
|
357
|
+
try {
|
|
358
|
+
await client.authenticate({
|
|
359
|
+
signal: controller.signal,
|
|
360
|
+
onAuthorizationUrl: async (url: URL) => {
|
|
361
|
+
await openAuthUrl(url.toString());
|
|
362
|
+
// Parity with the command path: announce the full URL too (the
|
|
363
|
+
// browser may be unavailable — headless, remote).
|
|
364
|
+
ctx.ui.notify(`Opening browser… if it didn't open, visit: ${url.toString()}`, "info");
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
// Flow finished authenticated: post-auth close + reconnect
|
|
368
|
+
// (re-reads the freshly stored token), structured result.
|
|
369
|
+
outcome = await reconnectAfterAuth(client);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
// An esc/ctrl+c abort rejects with exactly "OAuth cancelled" — that
|
|
372
|
+
// is a CANCELLATION. Anything else is a real flow failure.
|
|
373
|
+
outcome =
|
|
374
|
+
e instanceof Error && e.message === "OAuth cancelled"
|
|
375
|
+
? { kind: "cancelled" }
|
|
376
|
+
: { kind: "flow-error", error: e instanceof Error ? e.message : String(e) };
|
|
377
|
+
}
|
|
378
|
+
settleAuth(name, controller, outcome);
|
|
379
|
+
})();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function settleAuth(name: string, controller: AbortController, outcome: AuthRunOutcome): void {
|
|
383
|
+
// The panel was closed while the flow ran: don't touch a disposed tui,
|
|
384
|
+
// but still persist the settled outcome (ADR 0004).
|
|
385
|
+
if (disposed || authController !== controller) {
|
|
386
|
+
if (outcome.kind === "reconnected") {
|
|
387
|
+
const c = deps.getManager().getClient(name);
|
|
388
|
+
if (c) recordClientOutcome(c);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
state.authing = null;
|
|
393
|
+
authController = null;
|
|
394
|
+
if (outcome.kind === "cancelled") {
|
|
395
|
+
// esc abort (or an external abort) — cancellation, not an error.
|
|
396
|
+
setNotice("Authentication cancelled", "info");
|
|
397
|
+
} else if (outcome.kind === "flow-error") {
|
|
398
|
+
setNotice(`✗ ${name}: ${outcome.error}`, "error");
|
|
399
|
+
} else if (outcome.kind === "reconnect-failed") {
|
|
400
|
+
setNotice(`${name} is authenticated, but reconnecting failed: ${outcome.error}`, "error");
|
|
401
|
+
} else {
|
|
402
|
+
// reconnected — ADR 0004: record the settled outcome, refresh the row
|
|
403
|
+
// (status + live tools), success line.
|
|
404
|
+
const client = deps.getManager().getClient(name);
|
|
405
|
+
if (client) recordClientOutcome(client);
|
|
406
|
+
refreshRow(name);
|
|
407
|
+
setNotice(
|
|
408
|
+
outcome.status === "connected"
|
|
409
|
+
? `✓ ${name} authenticated — ${outcome.tools} tools available`
|
|
410
|
+
: `✓ ${name} authenticated and reconnected`,
|
|
411
|
+
"success",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
requestRender();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function saveSelection(): void {
|
|
418
|
+
if (state.changedServers.size === 0) {
|
|
419
|
+
ctx.ui.notify("/mcp panel: no changes to save", "info");
|
|
420
|
+
closePanel();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
let count = 0;
|
|
425
|
+
for (const name of state.changedServers) {
|
|
426
|
+
const s = rowByName(name);
|
|
427
|
+
if (s === undefined) continue;
|
|
428
|
+
// true = all direct, false = none, string[] = the subset.
|
|
429
|
+
writeServerDirectTools(ctx.cwd, name, computeSelection(s.tools));
|
|
430
|
+
count++;
|
|
431
|
+
}
|
|
432
|
+
ctx.ui.notify(
|
|
433
|
+
`✓ Saved direct tools for ${count} server${count === 1 ? "" : "s"} — run /reload to apply`,
|
|
434
|
+
"info",
|
|
435
|
+
);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
438
|
+
setNotice(message, "error");
|
|
439
|
+
ctx.ui.notify(`/mcp panel: ${message}`, "error");
|
|
440
|
+
requestRender();
|
|
441
|
+
return; // failed write → stay open so the user can fix and retry
|
|
442
|
+
}
|
|
443
|
+
closePanel();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Input ──────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
function handleInput(data: string): void {
|
|
449
|
+
// authing substate (ADR 0005): esc cancels, ctrl+c cancels + closes,
|
|
450
|
+
// EVERYTHING ELSE IS IGNORED until the flow settles.
|
|
451
|
+
if (state.authing !== null) {
|
|
452
|
+
if (matchesKey(data, Key.escape)) {
|
|
453
|
+
// Aborts the flow; it then rejects with "OAuth cancelled" and the
|
|
454
|
+
// settle renders "Authentication cancelled" (a cancellation, not an
|
|
455
|
+
// error).
|
|
456
|
+
authController?.abort();
|
|
457
|
+
} else if (matchesKey(data, Key.ctrl("c"))) {
|
|
458
|
+
authController?.abort();
|
|
459
|
+
closePanel();
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Close: esc/ctrl+c — discard, NO confirm (unsaved toggles are lost, by
|
|
465
|
+
// design). This also covers the filter state.
|
|
466
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
467
|
+
closePanel();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const rows = visibleRows();
|
|
472
|
+
|
|
473
|
+
if (matchesKey(data, Key.up)) {
|
|
474
|
+
if (state.cursor > 0) {
|
|
475
|
+
state.cursor--;
|
|
476
|
+
requestRender();
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (matchesKey(data, Key.down)) {
|
|
481
|
+
if (state.cursor < rows.length - 1) {
|
|
482
|
+
state.cursor++;
|
|
483
|
+
requestRender();
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (matchesKey(data, "/" )) {
|
|
489
|
+
state.filterMode = true;
|
|
490
|
+
requestRender();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (matchesKey(data, Key.backspace)) {
|
|
495
|
+
if (state.filter.length > 0) {
|
|
496
|
+
state.filter = state.filter.slice(0, -1);
|
|
497
|
+
state.cursor = 0;
|
|
498
|
+
requestRender();
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const selected = rows[state.cursor];
|
|
504
|
+
const server = selected?.kind === "server" ? selected.server : undefined;
|
|
505
|
+
const tool = selected?.kind === "tool" ? selected.tool : undefined;
|
|
506
|
+
|
|
507
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
508
|
+
saveSelection();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// enter: needs-auth server → IN-PANEL AUTH only (no expand — ADR 0005);
|
|
513
|
+
// any other server row → expand/collapse; tool rows are inert.
|
|
514
|
+
if (matchesKey(data, Key.enter)) {
|
|
515
|
+
if (server && server.status === "needs-auth") {
|
|
516
|
+
startAuth(server.name);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (server && state.filter.length === 0) {
|
|
520
|
+
server.expanded = !server.expanded;
|
|
521
|
+
clampCursor();
|
|
522
|
+
requestRender();
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// space: while filter mode is active or a filter is set, " " is a
|
|
528
|
+
// printable filter char (agent-manager printable set); otherwise
|
|
529
|
+
// toggle (server row → group, tool row → one).
|
|
530
|
+
if (matchesKey(data, Key.space) && state.filter.length === 0 && !state.filterMode) {
|
|
531
|
+
if (server) {
|
|
532
|
+
// Group toggle: all-off → all-on, otherwise all-off.
|
|
533
|
+
const target = !server.tools.some((t) => t.isDirect);
|
|
534
|
+
for (const t of server.tools) t.isDirect = target;
|
|
535
|
+
syncChanged();
|
|
536
|
+
requestRender();
|
|
537
|
+
} else if (tool) {
|
|
538
|
+
toggleTool(tool);
|
|
539
|
+
syncChanged();
|
|
540
|
+
requestRender();
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (state.filter.length > 0 || state.filterMode) {
|
|
546
|
+
// Filter mode: printable chars (including " ", which is in the
|
|
547
|
+
// [" ", "~"] range) append to the query; e/l/a/r are INERT so their
|
|
548
|
+
// letters can be typed; backspace is handled above. filterMode is
|
|
549
|
+
// reset on the first typed char (agent-manager parity).
|
|
550
|
+
if (data.length === 1 && data >= " " && data <= "~") {
|
|
551
|
+
state.filter += data;
|
|
552
|
+
state.filterMode = false;
|
|
553
|
+
state.cursor = 0;
|
|
554
|
+
requestRender();
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Filter empty — the single-char actions (server rows only).
|
|
560
|
+
if (server) {
|
|
561
|
+
if (matchesKey(data, "e")) {
|
|
562
|
+
toggleEnabled(server.name);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (matchesKey(data, "l")) {
|
|
566
|
+
logout(server.name);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (matchesKey(data, "a")) {
|
|
570
|
+
startAuth(server.name);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (matchesKey(data, "r")) {
|
|
574
|
+
reconnect(server.name);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
function statusGlyph(status: ServerRowStatus): string {
|
|
582
|
+
switch (status) {
|
|
583
|
+
case "connected":
|
|
584
|
+
return theme.fg("success", "●");
|
|
585
|
+
case "needs-auth":
|
|
586
|
+
return theme.fg("warning", "⚠");
|
|
587
|
+
case "error":
|
|
588
|
+
return theme.fg("error", "✗");
|
|
589
|
+
case "disabled":
|
|
590
|
+
return theme.fg("dim", "⊘");
|
|
591
|
+
case "cached":
|
|
592
|
+
return theme.fg("dim", "○");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function renderRowLine(row: VisibleRow, selected: boolean, contentWidth: number): string {
|
|
597
|
+
let text: string;
|
|
598
|
+
if (row.kind === "server") {
|
|
599
|
+
const s = row.server;
|
|
600
|
+
const caret = s.expanded ? "▾" : "▸";
|
|
601
|
+
const directCount = s.tools.filter((t) => t.isDirect).length;
|
|
602
|
+
text =
|
|
603
|
+
`${caret} ${statusGlyph(s.status)} ` +
|
|
604
|
+
theme.fg("accent", s.name) +
|
|
605
|
+
` (${directCount}/${s.tools.length} tools)` +
|
|
606
|
+
ageSuffix(s.statusAt);
|
|
607
|
+
} else {
|
|
608
|
+
const mark = row.tool.isDirect ? "●" : theme.fg("dim", "○");
|
|
609
|
+
const desc = row.tool.description.length > 0 ? ` ${theme.fg("dim", row.tool.description)}` : "";
|
|
610
|
+
text = ` ${mark} ${row.tool.name}${desc}`;
|
|
611
|
+
}
|
|
612
|
+
const line = selected ? theme.fg("accent", text) : text;
|
|
613
|
+
return hardTruncate(line, contentWidth);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderLines(contentWidth: number): string[] {
|
|
617
|
+
const lines: string[] = [];
|
|
618
|
+
|
|
619
|
+
// Header (accent), same shape as /agents.
|
|
620
|
+
lines.push(renderHeader(` MCP Servers [${state.servers.length}] `, contentWidth, theme));
|
|
621
|
+
|
|
622
|
+
// Active filter line (dim).
|
|
623
|
+
if (state.filter.length > 0) {
|
|
624
|
+
const marker = state.filterMode ? CURSOR_MARKER : "";
|
|
625
|
+
lines.push(padEnd(theme.fg("dim", `◎ ${state.filter}`) + marker, contentWidth));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Flat visible list, cursor row highlighted with accent.
|
|
629
|
+
const rows = visibleRows();
|
|
630
|
+
if (rows.length === 0) {
|
|
631
|
+
lines.push(
|
|
632
|
+
padEnd(
|
|
633
|
+
theme.fg(
|
|
634
|
+
"dim",
|
|
635
|
+
state.filter.length > 0 ? "No matching servers" : "No MCP servers configured",
|
|
636
|
+
),
|
|
637
|
+
contentWidth,
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
} else {
|
|
641
|
+
for (let i = 0; i < rows.length; i++) {
|
|
642
|
+
const row = rows[i];
|
|
643
|
+
if (row === undefined) continue;
|
|
644
|
+
lines.push(renderRowLine(row, i === state.cursor, contentWidth));
|
|
645
|
+
// First-line failure text under error/needs-auth rows (indented to
|
|
646
|
+
// align with the server name).
|
|
647
|
+
if (row.kind === "server") {
|
|
648
|
+
const s = row.server;
|
|
649
|
+
if ((s.status === "error" || s.status === "needs-auth") && s.failureMessage) {
|
|
650
|
+
lines.push(
|
|
651
|
+
hardTruncate(
|
|
652
|
+
theme.fg("dim", ` ${s.failureMessage}${ageSuffix(s.statusAt)}`),
|
|
653
|
+
contentWidth,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Transient result line (latest action's outcome).
|
|
662
|
+
if (state.notice) {
|
|
663
|
+
const token =
|
|
664
|
+
state.notice.tone === "error" ? "error" : state.notice.tone === "success" ? "success" : "dim";
|
|
665
|
+
lines.push(hardTruncate(theme.fg(token, state.notice.text), contentWidth));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Footer (dim, bracket hints). At the standard 84-char overlay width the
|
|
669
|
+
// single string is 116 visible chars > the 80-char content width and
|
|
670
|
+
// renderFooter does NOT wrap — so it is pre-split into two lines when
|
|
671
|
+
// (and only when) it overflows.
|
|
672
|
+
const single = HINTS_FIRST + HINTS_SECOND;
|
|
673
|
+
if (visibleWidth(single) <= contentWidth) {
|
|
674
|
+
lines.push(renderFooter(single, contentWidth, theme));
|
|
675
|
+
} else {
|
|
676
|
+
lines.push(renderFooter(HINTS_FIRST, contentWidth, theme));
|
|
677
|
+
lines.push(renderFooter(HINTS_SECOND, contentWidth, theme));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return lines;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
render(width: number): string[] {
|
|
685
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
686
|
+
const bordered = wrapWithBorder(renderLines(borderContentWidth(width)), width, theme);
|
|
687
|
+
cachedWidth = width;
|
|
688
|
+
cachedLines = bordered;
|
|
689
|
+
return bordered;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
handleInput,
|
|
693
|
+
|
|
694
|
+
invalidate(): void {
|
|
695
|
+
cachedWidth = undefined;
|
|
696
|
+
cachedLines = undefined;
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
dispose(): void {
|
|
700
|
+
disposed = true;
|
|
701
|
+
// Cancel a flow still in flight so it cannot touch the closed tui;
|
|
702
|
+
// settleAuth's disposed-guard keeps it from re-rendering.
|
|
703
|
+
authController?.abort();
|
|
704
|
+
authController = null;
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Open the management panel as a centered overlay (shared chrome, ADR 0003).
|
|
713
|
+
* `pi` is part of the stable panel API (the reference adapter's
|
|
714
|
+
* openMcpPanel takes it too) but unused here — the panel's tool list comes
|
|
715
|
+
* from the manager/cache, not `pi.getAllTools()`.
|
|
716
|
+
*/
|
|
717
|
+
export async function openMcpPanel(
|
|
718
|
+
pi: ExtensionAPI,
|
|
719
|
+
ctx: ExtensionCommandContext,
|
|
720
|
+
deps: McpPanelDeps,
|
|
721
|
+
): Promise<void> {
|
|
722
|
+
void pi;
|
|
723
|
+
if (!ctx.hasUI) {
|
|
724
|
+
ctx.ui.notify("/mcp panel requires an interactive TUI", "error");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const defs = deps.getServerDefs();
|
|
729
|
+
const sources: RowSources = {
|
|
730
|
+
globalConfig: loadMcpConfig(),
|
|
731
|
+
outcomes: loadMetadataCache().serverStatuses ?? {},
|
|
732
|
+
manager: deps.getManager(),
|
|
733
|
+
getCachedTools: deps.getCachedTools,
|
|
734
|
+
};
|
|
735
|
+
const servers = Object.entries(defs).map(([name, def]) => buildRow(name, def, sources));
|
|
736
|
+
|
|
737
|
+
await ctx.ui.custom<void>(
|
|
738
|
+
(tui: TUI, theme: Theme, _keybindings, done: () => void) =>
|
|
739
|
+
makeMcpPanel(servers, tui, theme, done, deps, ctx),
|
|
740
|
+
{ overlay: true, overlayOptions: OVERLAY_CHROME },
|
|
741
|
+
);
|
|
742
|
+
}
|