@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
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/mcp setup` onboarding panel (plan-027, Task 4).
|
|
3
|
+
*
|
|
4
|
+
* Mode-based `string[]` overlay built on the shared overlay chrome
|
|
5
|
+
* (`@pi-archimedes/core/overlay`) — structurally identical to the `/mcp panel`
|
|
6
|
+
* management panel (`panel.ts`) and the `/agents` manager: `renderHeader`
|
|
7
|
+
* at top, cursor-highlighted flat lists, bracket-hinted `renderFooter`
|
|
8
|
+
* lines at the bottom, everything wrapped by `wrapWithBorder` (ADR 0003).
|
|
9
|
+
* No pi-tui `SelectList`/`DynamicBorder`/`Input`/`Focusable`.
|
|
10
|
+
*
|
|
11
|
+
* Modes and keys:
|
|
12
|
+
* - `menu` (entry): "Scaffold minimal .mcp.json" / "Add a known server" /
|
|
13
|
+
* "Import from another tool" / "Cancel"; `enter` runs the selected action,
|
|
14
|
+
* `esc` closes
|
|
15
|
+
* - `import`: checklist of `discoverHostConfigs(ctx.cwd)` results
|
|
16
|
+
* (`[x] cursor ~/.cursor/mcp.json`); `space` toggles the check;
|
|
17
|
+
* `enter` on a CHECKED row previews (then confirms) just that host;
|
|
18
|
+
* `w` previews (then confirms) everything checked; `esc` → menu
|
|
19
|
+
* - `preview`: plain list of the server names to be added — the
|
|
20
|
+
* add-if-absent diff, so servers already in `.mcp.json` are NOT listed;
|
|
21
|
+
* `enter`/`y` writes the selection to `<cwd>/.mcp.json` via
|
|
22
|
+
* `mergeServerDefinitions` and returns to `import` with a success line;
|
|
23
|
+
* `n`/`esc` cancels
|
|
24
|
+
* - `known`: small curated `{ name, def }` list; `enter` adds that entry
|
|
25
|
+
* (add-if-absent — an existing entry is never overwritten); `esc` → menu
|
|
26
|
+
* - `scaffold`: shows the minimal `.mcp.json` content; `enter` writes it
|
|
27
|
+
* ONLY when `<cwd>/.mcp.json` is absent (never clobbers — when present,
|
|
28
|
+
* the panel says so); `esc` → menu
|
|
29
|
+
*
|
|
30
|
+
* All writes target `<cwd>/.mcp.json` — the project-shared DEFINITIONS file
|
|
31
|
+
* from the normal config cascade, NOT the Pi override under
|
|
32
|
+
* `<cwd>/<CONFIG_DIR_NAME>/`. Every successful write notifies the user to
|
|
33
|
+
* run `/reload`.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync } from "node:fs";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { join, sep } from "node:path";
|
|
38
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
39
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
40
|
+
import type {
|
|
41
|
+
ExtensionAPI,
|
|
42
|
+
ExtensionCommandContext,
|
|
43
|
+
Theme,
|
|
44
|
+
} from "@earendil-works/pi-coding-agent";
|
|
45
|
+
import {
|
|
46
|
+
OVERLAY_CHROME,
|
|
47
|
+
borderContentWidth,
|
|
48
|
+
hardTruncate,
|
|
49
|
+
padEnd,
|
|
50
|
+
renderFooter,
|
|
51
|
+
renderHeader,
|
|
52
|
+
visibleWidth,
|
|
53
|
+
wrapWithBorder,
|
|
54
|
+
} from "@pi-archimedes/core/overlay";
|
|
55
|
+
import { existingProjectServerNames, mergeServerDefinitions, writeJsonFileAtomic } from "./config-write.js";
|
|
56
|
+
import { discoverHostConfigs, type HostConfig } from "./host-configs.js";
|
|
57
|
+
import type { ServerDef } from "./types.js";
|
|
58
|
+
|
|
59
|
+
// ── Known servers (curated, deliberately small) ─────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface KnownServer {
|
|
62
|
+
name: string;
|
|
63
|
+
summary: string;
|
|
64
|
+
def: ServerDef;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The "Add a known server" presets. Keep this list small — it is surfaced
|
|
69
|
+
* verbatim in the TUI and every entry becomes a bare entry in `.mcp.json`.
|
|
70
|
+
*/
|
|
71
|
+
export const KNOWN_SERVERS: readonly KnownServer[] = [
|
|
72
|
+
{
|
|
73
|
+
name: "context7",
|
|
74
|
+
summary: "Upstash Context7 — up-to-date library docs",
|
|
75
|
+
def: { command: "npx", args: ["-y", "@upstash/context7-mcp"] },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "chrome-devtools",
|
|
79
|
+
summary: "Chrome DevTools MCP (official, Chrome team)",
|
|
80
|
+
def: { command: "npx", args: ["-y", "chrome-devtools-mcp@latest"] },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "deepwiki",
|
|
84
|
+
summary: "DeepWiki — ask questions about any GitHub repo",
|
|
85
|
+
def: { command: "npx", args: ["-y", "mcp-deepwiki@latest"] },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "fetch",
|
|
89
|
+
summary: "Fetch web pages as markdown (reference server)",
|
|
90
|
+
def: { command: "uvx", args: ["mcp-server-fetch"] },
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/** The minimal `.mcp.json` the scaffold mode writes (absent files only). */
|
|
95
|
+
export const SCAFFOLD_MCP_JSON = '{\n "mcpServers": {}\n}\n';
|
|
96
|
+
|
|
97
|
+
// ── Import preview (pure, exported for future unit tests) ───────────────────
|
|
98
|
+
|
|
99
|
+
/** The import preview: which names the add-if-absent write will actually add. */
|
|
100
|
+
export interface ImportPreview {
|
|
101
|
+
/** Which source(s) drive this preview (agent labels, `+`-joined). */
|
|
102
|
+
sourceLabel: string;
|
|
103
|
+
/** The server names that will be written (new ones only, first-seen order). */
|
|
104
|
+
adding: string[];
|
|
105
|
+
/** Selected servers already present in `.mcp.json` (kept untouched). */
|
|
106
|
+
alreadyPresent: number;
|
|
107
|
+
/** All selected defs — `mergeServerDefinitions` still applies add-if-absent. */
|
|
108
|
+
defs: Record<string, ServerDef>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Derive the preview for a set of checked host configs: union their servers
|
|
113
|
+
* in discovery order (first seen wins a name clash), then diff against the
|
|
114
|
+
* names already in `<cwd>/.mcp.json`. Returns null when nothing is selected.
|
|
115
|
+
*/
|
|
116
|
+
export function computeImportPreview(
|
|
117
|
+
hostConfigs: readonly HostConfig[],
|
|
118
|
+
checked: ReadonlySet<number>,
|
|
119
|
+
existingNames: readonly string[],
|
|
120
|
+
): ImportPreview | null {
|
|
121
|
+
const existing = new Set(existingNames);
|
|
122
|
+
const defs: Record<string, ServerDef> = {};
|
|
123
|
+
const order: string[] = [];
|
|
124
|
+
const labels: string[] = [];
|
|
125
|
+
for (let i = 0; i < hostConfigs.length; i++) {
|
|
126
|
+
if (!checked.has(i)) continue;
|
|
127
|
+
const host = hostConfigs[i];
|
|
128
|
+
if (host === undefined) continue;
|
|
129
|
+
labels.push(host.agent);
|
|
130
|
+
for (const [name, def] of Object.entries(host.servers)) {
|
|
131
|
+
if (!(name in defs)) {
|
|
132
|
+
defs[name] = def;
|
|
133
|
+
order.push(name);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (order.length === 0) return null;
|
|
138
|
+
const adding = order.filter((n) => !existing.has(n));
|
|
139
|
+
return {
|
|
140
|
+
sourceLabel: labels.join(" + "),
|
|
141
|
+
adding,
|
|
142
|
+
alreadyPresent: order.length - adding.length,
|
|
143
|
+
defs,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Component ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
type SetupMode = "menu" | "import" | "preview" | "known" | "scaffold";
|
|
150
|
+
|
|
151
|
+
interface SetupPanelState {
|
|
152
|
+
mode: SetupMode;
|
|
153
|
+
menuCursor: number;
|
|
154
|
+
importCursor: number;
|
|
155
|
+
knownCursor: number;
|
|
156
|
+
hostConfigs: HostConfig[];
|
|
157
|
+
/** True after the first import-mode discovery (re-discovered each entry). */
|
|
158
|
+
discovered: boolean;
|
|
159
|
+
/** Indices into hostConfigs the user checked ([space]). */
|
|
160
|
+
checked: Set<number>;
|
|
161
|
+
preview: ImportPreview | null;
|
|
162
|
+
/** Transient result line rendered above the footer. */
|
|
163
|
+
notice: { text: string; tone: "info" | "success" | "error" } | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface McpSetupPanelComponent {
|
|
167
|
+
render(width: number): string[];
|
|
168
|
+
handleInput(data: string): void;
|
|
169
|
+
invalidate(): void;
|
|
170
|
+
dispose(): void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const MENU_ACTIONS = [
|
|
174
|
+
"Scaffold minimal .mcp.json",
|
|
175
|
+
"Add a known server",
|
|
176
|
+
"Import from another tool",
|
|
177
|
+
"Cancel",
|
|
178
|
+
] as const;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Footer hint parts per mode (bracket style). The parts are joined and
|
|
182
|
+
* rendered as ONE footer line when they fit `borderContentWidth`; otherwise
|
|
183
|
+
* they are pre-split one per line (renderFooter does not wrap).
|
|
184
|
+
*/
|
|
185
|
+
const FOOTERS: Record<SetupMode, readonly string[]> = {
|
|
186
|
+
menu: [" [↑/↓] move [enter] select [esc] close "],
|
|
187
|
+
import: [
|
|
188
|
+
" [↑/↓] move [space] check [enter] preview checked ",
|
|
189
|
+
"[w] write all checked [esc] back ",
|
|
190
|
+
],
|
|
191
|
+
preview: [" [enter] write [n / esc] cancel "],
|
|
192
|
+
known: [" [↑/↓] move [enter] add [esc] back "],
|
|
193
|
+
scaffold: [" [enter] write (if absent) [esc] back "],
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Path with `~` substituted for the home prefix (display only). */
|
|
197
|
+
function displayPath(p: string): string {
|
|
198
|
+
const home = homedir();
|
|
199
|
+
if (p.startsWith(home + sep)) return `~${p.slice(home.length)}`;
|
|
200
|
+
return p;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the overlay component. `ctx` is captured for `ctx.cwd` (the
|
|
205
|
+
* `<cwd>/.mcp.json` write target) and `ctx.ui.notify` (toast parity with the
|
|
206
|
+
* command path); everything else is a direct import.
|
|
207
|
+
*/
|
|
208
|
+
function makeSetupPanel(
|
|
209
|
+
tui: TUI,
|
|
210
|
+
theme: Theme,
|
|
211
|
+
done: () => void,
|
|
212
|
+
ctx: ExtensionCommandContext,
|
|
213
|
+
): McpSetupPanelComponent {
|
|
214
|
+
const state: SetupPanelState = {
|
|
215
|
+
mode: "menu",
|
|
216
|
+
menuCursor: 0,
|
|
217
|
+
importCursor: 0,
|
|
218
|
+
knownCursor: 0,
|
|
219
|
+
hostConfigs: [],
|
|
220
|
+
discovered: false,
|
|
221
|
+
checked: new Set<number>(),
|
|
222
|
+
preview: null,
|
|
223
|
+
notice: null,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let disposed = false;
|
|
227
|
+
let cachedWidth: number | undefined;
|
|
228
|
+
let cachedLines: string[] | undefined;
|
|
229
|
+
|
|
230
|
+
function requestRender(): void {
|
|
231
|
+
cachedWidth = undefined;
|
|
232
|
+
cachedLines = undefined;
|
|
233
|
+
if (!disposed) tui.requestRender();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setNotice(text: string, tone: "info" | "success" | "error"): void {
|
|
237
|
+
state.notice = { text, tone };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function closePanel(): void {
|
|
241
|
+
if (disposed) return;
|
|
242
|
+
disposed = true;
|
|
243
|
+
done();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function projectTarget(): string {
|
|
247
|
+
return join(ctx.cwd, ".mcp.json");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Mode transitions ─────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function enterMenu(): void {
|
|
253
|
+
state.mode = "menu";
|
|
254
|
+
state.preview = null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function enterImportMode(): void {
|
|
258
|
+
// Re-discover so the checklist matches current reality; checks that
|
|
259
|
+
// reference paths still present are preserved across menu round-trips.
|
|
260
|
+
const previousChecked = new Map<string, boolean>();
|
|
261
|
+
for (let i = 0; i < state.hostConfigs.length; i++) {
|
|
262
|
+
const host = state.hostConfigs[i];
|
|
263
|
+
if (host !== undefined) previousChecked.set(host.path, state.checked.has(i));
|
|
264
|
+
}
|
|
265
|
+
let fresh: HostConfig[];
|
|
266
|
+
try {
|
|
267
|
+
fresh = discoverHostConfigs(ctx.cwd);
|
|
268
|
+
} catch {
|
|
269
|
+
fresh = state.hostConfigs; // defensive: never throw into the input path
|
|
270
|
+
}
|
|
271
|
+
state.hostConfigs = fresh;
|
|
272
|
+
state.discovered = true;
|
|
273
|
+
state.checked = new Set(
|
|
274
|
+
fresh
|
|
275
|
+
.map((host, i) => (previousChecked.get(host.path) === true ? i : -1))
|
|
276
|
+
.filter((i) => i >= 0),
|
|
277
|
+
);
|
|
278
|
+
state.importCursor = Math.min(state.importCursor, Math.max(0, fresh.length - 1));
|
|
279
|
+
state.mode = "import";
|
|
280
|
+
state.preview = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Actions ──────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function runMenuAction(): void {
|
|
286
|
+
const action = MENU_ACTIONS[state.menuCursor];
|
|
287
|
+
switch (action) {
|
|
288
|
+
case "Scaffold minimal .mcp.json":
|
|
289
|
+
state.mode = "scaffold";
|
|
290
|
+
requestRender();
|
|
291
|
+
return;
|
|
292
|
+
case "Add a known server":
|
|
293
|
+
state.mode = "known";
|
|
294
|
+
requestRender();
|
|
295
|
+
return;
|
|
296
|
+
case "Import from another tool":
|
|
297
|
+
enterImportMode();
|
|
298
|
+
requestRender();
|
|
299
|
+
return;
|
|
300
|
+
case "Cancel":
|
|
301
|
+
default:
|
|
302
|
+
closePanel();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Build and show the preview for the given host indices (checked set for
|
|
309
|
+
* `w`, a single index for `enter` on a checked row). Refuses to open when
|
|
310
|
+
* nothing NEW would be added, and on an unreadable `.mcp.json` it surfaces
|
|
311
|
+
* the reader's error instead of guessing the diff.
|
|
312
|
+
*/
|
|
313
|
+
function openPreviewFor(indices: number[]): void {
|
|
314
|
+
let existingNames: string[];
|
|
315
|
+
try {
|
|
316
|
+
existingNames = existingProjectServerNames(ctx.cwd);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
319
|
+
setNotice(message, "error");
|
|
320
|
+
ctx.ui.notify(`/mcp setup: ${message}`, "error");
|
|
321
|
+
requestRender();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const preview = computeImportPreview(state.hostConfigs, new Set(indices), existingNames);
|
|
325
|
+
if (preview === null || preview.adding.length === 0) {
|
|
326
|
+
setNotice("Nothing to add — the selected hosts contribute no new servers", "info");
|
|
327
|
+
requestRender();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
state.preview = preview;
|
|
331
|
+
state.notice = null;
|
|
332
|
+
state.mode = "preview";
|
|
333
|
+
requestRender();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function cancelPreview(): void {
|
|
337
|
+
state.preview = null;
|
|
338
|
+
state.mode = "import";
|
|
339
|
+
requestRender();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function confirmPreview(): void {
|
|
343
|
+
const preview = state.preview;
|
|
344
|
+
if (preview === null) {
|
|
345
|
+
cancelPreview();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
// The writer is add-if-absent — existing entries are untouched.
|
|
350
|
+
mergeServerDefinitions(ctx.cwd, preview.defs);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
// Failed write → stay in preview so the user can back out and retry.
|
|
353
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
354
|
+
setNotice(message, "error");
|
|
355
|
+
ctx.ui.notify(`/mcp setup: ${message}`, "error");
|
|
356
|
+
requestRender();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
state.checked.clear();
|
|
360
|
+
const n = preview.adding.length;
|
|
361
|
+
const summary = `✓ Added ${n} server${n === 1 ? "" : "s"} to .mcp.json — run /reload`;
|
|
362
|
+
state.preview = null;
|
|
363
|
+
setNotice(summary, "success");
|
|
364
|
+
ctx.ui.notify(`${summary} to apply`, "info");
|
|
365
|
+
state.mode = "import";
|
|
366
|
+
requestRender();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function addKnown(): void {
|
|
370
|
+
const known = KNOWN_SERVERS[state.knownCursor];
|
|
371
|
+
if (known === undefined) return;
|
|
372
|
+
try {
|
|
373
|
+
if (existingProjectServerNames(ctx.cwd).includes(known.name)) {
|
|
374
|
+
setNotice(`${known.name} is already in .mcp.json — kept as-is`, "info");
|
|
375
|
+
ctx.ui.notify(`/mcp setup: ${known.name} already present in .mcp.json — kept as-is`, "info");
|
|
376
|
+
requestRender();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
mergeServerDefinitions(ctx.cwd, { [known.name]: known.def });
|
|
380
|
+
} catch (e) {
|
|
381
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
382
|
+
setNotice(message, "error");
|
|
383
|
+
ctx.ui.notify(`/mcp setup: ${message}`, "error");
|
|
384
|
+
requestRender();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
setNotice(`✓ Added ${known.name} to .mcp.json — run /reload`, "success");
|
|
388
|
+
ctx.ui.notify(`✓ Added ${known.name} to .mcp.json — run /reload to apply`, "info");
|
|
389
|
+
requestRender();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function runScaffold(): void {
|
|
393
|
+
const target = projectTarget();
|
|
394
|
+
if (existsSync(target)) {
|
|
395
|
+
// Never clobber an existing project file — say so instead.
|
|
396
|
+
setNotice(".mcp.json already exists — not modified", "info");
|
|
397
|
+
ctx.ui.notify("/mcp setup: .mcp.json already exists — not modified", "info");
|
|
398
|
+
requestRender();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
// Atomic tmp+rename in the same dir via the shared writer — a
|
|
403
|
+
// 2-space stringification of the parsed scaffold is byte-identical
|
|
404
|
+
// to SCAFFOLD_MCP_JSON itself.
|
|
405
|
+
writeJsonFileAtomic(target, JSON.parse(SCAFFOLD_MCP_JSON));
|
|
406
|
+
} catch (e) {
|
|
407
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
408
|
+
setNotice(message, "error");
|
|
409
|
+
ctx.ui.notify(`/mcp setup: ${message}`, "error");
|
|
410
|
+
requestRender();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
setNotice("✓ .mcp.json created — run /reload", "success");
|
|
414
|
+
ctx.ui.notify("✓ Scaffolded .mcp.json — run /reload to apply", "info");
|
|
415
|
+
requestRender();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Input (per-mode handlers) ────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function handleMenuInput(data: string): void {
|
|
421
|
+
if (matchesKey(data, Key.up)) {
|
|
422
|
+
if (state.menuCursor > 0) {
|
|
423
|
+
state.menuCursor--;
|
|
424
|
+
requestRender();
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (matchesKey(data, Key.down)) {
|
|
429
|
+
if (state.menuCursor < MENU_ACTIONS.length - 1) {
|
|
430
|
+
state.menuCursor++;
|
|
431
|
+
requestRender();
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (matchesKey(data, Key.enter)) runMenuAction();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function handleImportInput(data: string): void {
|
|
439
|
+
const rows = state.hostConfigs.length;
|
|
440
|
+
if (matchesKey(data, Key.up)) {
|
|
441
|
+
if (state.importCursor > 0) {
|
|
442
|
+
state.importCursor--;
|
|
443
|
+
requestRender();
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, Key.down)) {
|
|
448
|
+
if (rows > 0 && state.importCursor < rows - 1) {
|
|
449
|
+
state.importCursor++;
|
|
450
|
+
requestRender();
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (rows === 0) return;
|
|
455
|
+
if (matchesKey(data, Key.space)) {
|
|
456
|
+
const i = state.importCursor;
|
|
457
|
+
if (state.hostConfigs[i] === undefined) return;
|
|
458
|
+
if (state.checked.has(i)) state.checked.delete(i);
|
|
459
|
+
else state.checked.add(i);
|
|
460
|
+
requestRender();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (matchesKey(data, Key.enter)) {
|
|
464
|
+
// Preview (then confirm) just the checked row under the cursor.
|
|
465
|
+
if (state.checked.has(state.importCursor)) openPreviewFor([state.importCursor]);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (matchesKey(data, "w")) {
|
|
469
|
+
const checked = [...state.checked].sort((a, b) => a - b);
|
|
470
|
+
if (checked.length === 0) {
|
|
471
|
+
setNotice("Check at least one source first ([space])", "info");
|
|
472
|
+
requestRender();
|
|
473
|
+
} else {
|
|
474
|
+
openPreviewFor(checked);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function handlePreviewInput(data: string): void {
|
|
480
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, "y")) {
|
|
481
|
+
confirmPreview();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (matchesKey(data, "n")) cancelPreview();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function handleKnownInput(data: string): void {
|
|
488
|
+
if (matchesKey(data, Key.up)) {
|
|
489
|
+
if (state.knownCursor > 0) {
|
|
490
|
+
state.knownCursor--;
|
|
491
|
+
requestRender();
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (matchesKey(data, Key.down)) {
|
|
496
|
+
if (state.knownCursor < KNOWN_SERVERS.length - 1) {
|
|
497
|
+
state.knownCursor++;
|
|
498
|
+
requestRender();
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (matchesKey(data, Key.enter)) addKnown();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function handleScaffoldInput(data: string): void {
|
|
506
|
+
if (matchesKey(data, Key.enter)) runScaffold();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function handleInput(data: string): void {
|
|
510
|
+
// ctrl+c closes from ANY mode (no confirm — nothing is written until a
|
|
511
|
+
// confirm key is pressed, so there is nothing to lose).
|
|
512
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
513
|
+
closePanel();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// esc backs out a sub-mode; in the menu it closes the panel.
|
|
517
|
+
if (matchesKey(data, Key.escape)) {
|
|
518
|
+
if (state.mode === "menu") closePanel();
|
|
519
|
+
else enterMenu();
|
|
520
|
+
requestRender();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
switch (state.mode) {
|
|
524
|
+
case "menu":
|
|
525
|
+
handleMenuInput(data);
|
|
526
|
+
break;
|
|
527
|
+
case "import":
|
|
528
|
+
handleImportInput(data);
|
|
529
|
+
break;
|
|
530
|
+
case "preview":
|
|
531
|
+
handlePreviewInput(data);
|
|
532
|
+
break;
|
|
533
|
+
case "known":
|
|
534
|
+
handleKnownInput(data);
|
|
535
|
+
break;
|
|
536
|
+
case "scaffold":
|
|
537
|
+
handleScaffoldInput(data);
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
function headerText(): string {
|
|
545
|
+
switch (state.mode) {
|
|
546
|
+
case "menu":
|
|
547
|
+
return " MCP Setup ";
|
|
548
|
+
case "import":
|
|
549
|
+
return " MCP Setup — Import from another tool ";
|
|
550
|
+
case "preview":
|
|
551
|
+
return " MCP Setup — Confirm import ";
|
|
552
|
+
case "known":
|
|
553
|
+
return " MCP Setup — Known servers ";
|
|
554
|
+
case "scaffold":
|
|
555
|
+
return " MCP Setup — Scaffold .mcp.json ";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderBody(contentWidth: number): string[] {
|
|
560
|
+
switch (state.mode) {
|
|
561
|
+
case "menu": {
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
lines.push(theme.fg("dim", "Get started with MCP servers in this project:"));
|
|
564
|
+
for (let i = 0; i < MENU_ACTIONS.length; i++) {
|
|
565
|
+
const label = MENU_ACTIONS[i];
|
|
566
|
+
if (label === undefined) continue;
|
|
567
|
+
const prefix = i === state.menuCursor ? theme.fg("accent", "> ") : " ";
|
|
568
|
+
lines.push(hardTruncate(prefix + label, contentWidth));
|
|
569
|
+
}
|
|
570
|
+
return lines;
|
|
571
|
+
}
|
|
572
|
+
case "import": {
|
|
573
|
+
const lines: string[] = [];
|
|
574
|
+
if (state.hostConfigs.length === 0) {
|
|
575
|
+
lines.push(theme.fg("dim", "No other-tool MCP configs found."));
|
|
576
|
+
lines.push(theme.fg("dim", "(looked for cursor, claude-code, claude-desktop, vscode)"));
|
|
577
|
+
return lines;
|
|
578
|
+
}
|
|
579
|
+
for (let i = 0; i < state.hostConfigs.length; i++) {
|
|
580
|
+
const host = state.hostConfigs[i];
|
|
581
|
+
if (host === undefined) continue;
|
|
582
|
+
const count = Object.keys(host.servers).length;
|
|
583
|
+
let line = `${state.checked.has(i) ? "[x]" : "[ ]"} ${padEnd(host.agent, 14)} ` +
|
|
584
|
+
`${displayPath(host.path)} ${theme.fg("dim", `(${count})`)}`;
|
|
585
|
+
if (i === state.importCursor) line = theme.fg("accent", line);
|
|
586
|
+
lines.push(hardTruncate(line, contentWidth));
|
|
587
|
+
}
|
|
588
|
+
return lines;
|
|
589
|
+
}
|
|
590
|
+
case "preview": {
|
|
591
|
+
const lines: string[] = [];
|
|
592
|
+
const preview = state.preview;
|
|
593
|
+
if (preview === null) {
|
|
594
|
+
lines.push(theme.fg("dim", "Nothing to preview."));
|
|
595
|
+
return lines;
|
|
596
|
+
}
|
|
597
|
+
lines.push(`Source : ${preview.sourceLabel}`);
|
|
598
|
+
lines.push(`Target : ${displayPath(projectTarget())}`);
|
|
599
|
+
const n = preview.adding.length;
|
|
600
|
+
lines.push(`Will add ${n} server${n === 1 ? "" : "s"}:`);
|
|
601
|
+
for (const name of preview.adding) lines.push(` ${name}`);
|
|
602
|
+
if (preview.alreadyPresent > 0) {
|
|
603
|
+
lines.push(theme.fg("dim", `(${preview.alreadyPresent} already present — kept as-is)`));
|
|
604
|
+
}
|
|
605
|
+
return lines;
|
|
606
|
+
}
|
|
607
|
+
case "known": {
|
|
608
|
+
const lines: string[] = [];
|
|
609
|
+
for (let i = 0; i < KNOWN_SERVERS.length; i++) {
|
|
610
|
+
const known = KNOWN_SERVERS[i];
|
|
611
|
+
if (known === undefined) continue;
|
|
612
|
+
let line = `${padEnd(known.name, 16)} ${known.summary}`;
|
|
613
|
+
if (i === state.knownCursor) line = theme.fg("accent", line);
|
|
614
|
+
lines.push(hardTruncate(line, contentWidth));
|
|
615
|
+
}
|
|
616
|
+
lines.push(theme.fg("dim", "add-if-absent: an existing entry is never overwritten"));
|
|
617
|
+
return lines;
|
|
618
|
+
}
|
|
619
|
+
case "scaffold": {
|
|
620
|
+
const lines: string[] = [];
|
|
621
|
+
const exists = existsSync(projectTarget());
|
|
622
|
+
lines.push(
|
|
623
|
+
theme.fg(
|
|
624
|
+
"dim",
|
|
625
|
+
exists
|
|
626
|
+
? "Target file already exists — it will NOT be modified."
|
|
627
|
+
: "Target file is absent — [enter] creates it.",
|
|
628
|
+
),
|
|
629
|
+
);
|
|
630
|
+
for (const jsonLine of SCAFFOLD_MCP_JSON.trimEnd().split("\n")) {
|
|
631
|
+
lines.push(theme.fg("dim", jsonLine));
|
|
632
|
+
}
|
|
633
|
+
return lines;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function renderFooters(contentWidth: number): string[] {
|
|
639
|
+
const parts = FOOTERS[state.mode];
|
|
640
|
+
const single = parts.join("");
|
|
641
|
+
// At the standard 84-char overlay width the import footer overflows the
|
|
642
|
+
// 80-char content width and renderFooter does NOT wrap — so it is
|
|
643
|
+
// pre-split into lines only when it overflows.
|
|
644
|
+
if (visibleWidth(single) <= contentWidth) {
|
|
645
|
+
return [renderFooter(single, contentWidth, theme)];
|
|
646
|
+
}
|
|
647
|
+
return parts.map((part) => renderFooter(part, contentWidth, theme));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function renderLines(contentWidth: number): string[] {
|
|
651
|
+
const lines: string[] = [];
|
|
652
|
+
|
|
653
|
+
lines.push(renderHeader(headerText(), contentWidth, theme));
|
|
654
|
+
|
|
655
|
+
for (const line of renderBody(contentWidth)) {
|
|
656
|
+
lines.push(hardTruncate(line, contentWidth));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Transient result line (latest action's outcome).
|
|
660
|
+
if (state.notice !== null) {
|
|
661
|
+
const token =
|
|
662
|
+
state.notice.tone === "error" ? "error" : state.notice.tone === "success" ? "success" : "dim";
|
|
663
|
+
lines.push(hardTruncate(theme.fg(token, state.notice.text), contentWidth));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
for (const footer of renderFooters(contentWidth)) lines.push(footer);
|
|
667
|
+
|
|
668
|
+
return lines;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
render(width: number): string[] {
|
|
673
|
+
if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
|
|
674
|
+
const bordered = wrapWithBorder(renderLines(borderContentWidth(width)), width, theme);
|
|
675
|
+
cachedWidth = width;
|
|
676
|
+
cachedLines = bordered;
|
|
677
|
+
return bordered;
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
handleInput,
|
|
681
|
+
|
|
682
|
+
invalidate(): void {
|
|
683
|
+
cachedWidth = undefined;
|
|
684
|
+
cachedLines = undefined;
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
dispose(): void {
|
|
688
|
+
disposed = true;
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Open the setup panel as a centered overlay (shared chrome, ADR 0003).
|
|
697
|
+
* `pi` participates in the stable panel API (the management panel takes it
|
|
698
|
+
* too) but is unused here — the setup flow only writes `<cwd>/.mcp.json`.
|
|
699
|
+
*/
|
|
700
|
+
export async function openMcpSetupPanel(
|
|
701
|
+
pi: ExtensionAPI,
|
|
702
|
+
ctx: ExtensionCommandContext,
|
|
703
|
+
): Promise<void> {
|
|
704
|
+
void pi;
|
|
705
|
+
if (!ctx.hasUI) {
|
|
706
|
+
ctx.ui.notify("/mcp setup requires an interactive TUI", "error");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await ctx.ui.custom<void>(
|
|
711
|
+
(tui: TUI, theme: Theme, _keybindings, done: () => void) =>
|
|
712
|
+
makeSetupPanel(tui, theme, done, ctx),
|
|
713
|
+
{ overlay: true, overlayOptions: OVERLAY_CHROME },
|
|
714
|
+
);
|
|
715
|
+
}
|