@pi-unipi/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * @pi-unipi/mcp — Add server overlay TUI
3
+ *
4
+ * Split-pane overlay for adding MCP servers:
5
+ * Left: browsable/searchable catalog list
6
+ * Right: JSON config editor for selected server
7
+ */
8
+
9
+ import {
10
+ Editor,
11
+ type EditorTheme,
12
+ Key,
13
+ matchesKey,
14
+ truncateToWidth,
15
+ visibleWidth,
16
+ } from "@mariozechner/pi-tui";
17
+ import type { CatalogEntry, CatalogData, McpConfig, McpMetadata } from "../types.js";
18
+ import { loadCatalog } from "../config/sync.js";
19
+ import {
20
+ loadMcpConfig,
21
+ saveMcpConfig,
22
+ loadMetadata,
23
+ saveMetadata,
24
+ getGlobalConfigDir,
25
+ } from "../config/manager.js";
26
+ import { validateMcpConfig, createServerTemplate, DEFAULT_MCP_CONFIG, DEFAULT_METADATA } from "../config/schema.js";
27
+
28
+ /** State for the add overlay */
29
+ interface AddOverlayState {
30
+ mode: "browse" | "custom";
31
+ searchQuery: string;
32
+ filteredServers: CatalogEntry[];
33
+ allServers: CatalogEntry[];
34
+ selectedIndex: number;
35
+ editorContent: string;
36
+ focusPane: "browse" | "editor";
37
+ validationError: string | null;
38
+ scope: "global" | "project";
39
+ saved: boolean;
40
+ }
41
+
42
+ /**
43
+ * Generate a pre-filled JSON config template from a catalog entry.
44
+ */
45
+ function generateConfigTemplate(server: CatalogEntry): string {
46
+ const name = server.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
47
+
48
+ const config: Record<string, unknown> = {
49
+ mcpServers: {
50
+ [name]: {
51
+ command: server.install?.command ?? "npx",
52
+ args: server.install?.args ?? ["-y", `@modelcontextprotocol/server-${name}`],
53
+ env: {},
54
+ },
55
+ },
56
+ };
57
+
58
+ // Pre-fill env vars with placeholders
59
+ if (server.install?.envVars) {
60
+ const env: Record<string, string> = {};
61
+ for (const v of server.install.envVars) {
62
+ env[v] = "";
63
+ }
64
+ (config.mcpServers as any)[name].env = env;
65
+ }
66
+
67
+ return JSON.stringify(config, null, 2);
68
+ }
69
+
70
+ /**
71
+ * Render the MCP add overlay.
72
+ *
73
+ * Returns a callback compatible with ctx.ui.custom():
74
+ * (tui, theme, kb, done) => { render, invalidate, handleInput }
75
+ */
76
+ export function renderMcpAddOverlay(params?: {
77
+ scope?: "global" | "project";
78
+ onComplete?: () => void;
79
+ }) {
80
+ return (
81
+ tui: any,
82
+ theme: any,
83
+ _kb: any,
84
+ done: (result: { saved: boolean } | null) => void,
85
+ ) => {
86
+ const scope = params?.scope ?? "global";
87
+
88
+ // State
89
+ const state: AddOverlayState = {
90
+ mode: "browse",
91
+ searchQuery: "",
92
+ filteredServers: [],
93
+ allServers: [],
94
+ selectedIndex: 0,
95
+ editorContent: "{}",
96
+ focusPane: "browse",
97
+ validationError: null,
98
+ scope,
99
+ saved: false,
100
+ };
101
+
102
+ // Load catalog
103
+ let catalog: CatalogData;
104
+ try {
105
+ catalog = loadCatalog();
106
+ state.allServers = catalog.servers;
107
+ state.filteredServers = catalog.servers;
108
+ } catch {
109
+ catalog = { lastUpdated: "", source: "", totalServers: 0, servers: [] };
110
+ state.allServers = [];
111
+ state.filteredServers = [];
112
+ }
113
+
114
+ // Editor theme
115
+ const editorTheme: EditorTheme = {
116
+ borderColor: (s: any) => theme.fg("accent", s),
117
+ selectList: {
118
+ selectedPrefix: (t: any) => theme.fg("accent", t),
119
+ selectedText: (t: any) => theme.fg("accent", t),
120
+ description: (t: any) => theme.fg("muted", t),
121
+ scrollInfo: (t: any) => theme.fg("dim", t),
122
+ noMatch: (t: any) => theme.fg("warning", t),
123
+ },
124
+ };
125
+ const editor = new Editor(tui, editorTheme);
126
+ editor.setText(state.editorContent);
127
+
128
+ let cachedLines: string[] | undefined;
129
+
130
+ function refresh() {
131
+ cachedLines = undefined;
132
+ tui.requestRender();
133
+ }
134
+
135
+ function searchServers(query: string) {
136
+ if (!query.trim()) {
137
+ state.filteredServers = state.allServers;
138
+ } else {
139
+ const q = query.toLowerCase();
140
+ state.filteredServers = state.allServers.filter(
141
+ (s) =>
142
+ s.name.toLowerCase().includes(q) ||
143
+ s.description.toLowerCase().includes(q) ||
144
+ s.categories.some((c) => c.toLowerCase().includes(q)),
145
+ );
146
+ }
147
+ state.selectedIndex = 0;
148
+ }
149
+
150
+ function selectServer(index: number) {
151
+ if (index < 0 || index >= state.filteredServers.length) return;
152
+ state.selectedIndex = index;
153
+ const server = state.filteredServers[index];
154
+ state.editorContent = generateConfigTemplate(server);
155
+ editor.setText(state.editorContent);
156
+ state.validationError = null;
157
+ state.focusPane = "editor";
158
+ state.mode = "browse";
159
+ }
160
+
161
+ function validateAndSave(): boolean {
162
+ try {
163
+ const parsed = JSON.parse(state.editorContent);
164
+ const validation = validateMcpConfig(parsed);
165
+ if (!validation.valid) {
166
+ state.validationError = validation.errors[0];
167
+ return false;
168
+ }
169
+
170
+ // Determine target directory
171
+ const configDir = getGlobalConfigDir();
172
+
173
+ // Load existing config and merge — handle corrupt existing config
174
+ let existing: McpConfig;
175
+ try {
176
+ existing = loadMcpConfig(configDir);
177
+ } catch {
178
+ // Existing config is corrupt — start fresh
179
+ existing = { ...DEFAULT_MCP_CONFIG };
180
+ }
181
+
182
+ const newServers = parsed.mcpServers ?? {};
183
+
184
+ for (const [name, def] of Object.entries(newServers)) {
185
+ existing.mcpServers[name] = def as any;
186
+ }
187
+
188
+ saveMcpConfig(configDir, existing);
189
+
190
+ // Update metadata
191
+ let meta: McpMetadata;
192
+ try {
193
+ meta = loadMetadata(configDir);
194
+ } catch {
195
+ meta = { ...DEFAULT_METADATA, servers: {}, sync: { ...DEFAULT_METADATA.sync } };
196
+ }
197
+ for (const name of Object.keys(newServers)) {
198
+ meta.servers[name] = {
199
+ enabled: true,
200
+ addedAt: new Date().toISOString(),
201
+ };
202
+ }
203
+ saveMetadata(configDir, meta);
204
+
205
+ state.saved = true;
206
+ state.validationError = null;
207
+ return true;
208
+ } catch (err) {
209
+ state.validationError =
210
+ err instanceof Error ? err.message : "Invalid JSON";
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function handleInput(data: string) {
216
+ // Editor pane focused: delegate to editor
217
+ if (state.focusPane === "editor") {
218
+ if (matchesKey(data, Key.escape)) {
219
+ // Cancel editing, go back to browse
220
+ state.focusPane = "browse";
221
+ state.validationError = null;
222
+ refresh();
223
+ return;
224
+ }
225
+
226
+ // Ctrl+S or Enter in editor: validate and save
227
+ if (
228
+ (data === "\x13" || data === "\r") &&
229
+ state.editorContent.trim() !== "{}"
230
+ ) {
231
+ if (validateAndSave()) {
232
+ done({ saved: true });
233
+ params?.onComplete?.();
234
+ } else {
235
+ refresh();
236
+ }
237
+ return;
238
+ }
239
+
240
+ // Delegate to editor
241
+ editor.handleInput(data);
242
+ state.editorContent = editor.getText();
243
+ refresh();
244
+ return;
245
+ }
246
+
247
+ // Browse pane focused
248
+ if (matchesKey(data, Key.escape)) {
249
+ done(null);
250
+ return;
251
+ }
252
+
253
+ // Navigation
254
+ if (matchesKey(data, Key.up)) {
255
+ if (state.selectedIndex > 0) {
256
+ state.selectedIndex--;
257
+ refresh();
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (matchesKey(data, Key.down)) {
263
+ if (state.selectedIndex < state.filteredServers.length - 1) {
264
+ state.selectedIndex++;
265
+ refresh();
266
+ }
267
+ return;
268
+ }
269
+
270
+ // Enter: select server and move to editor
271
+ if (data === "\r" || data === "\n") {
272
+ selectServer(state.selectedIndex);
273
+ refresh();
274
+ return;
275
+ }
276
+
277
+ // Tab: toggle focus between panes
278
+ if (data === "\t") {
279
+ state.focusPane = state.focusPane === "browse" ? "editor" : "browse";
280
+ refresh();
281
+ return;
282
+ }
283
+
284
+ // 'c': switch to custom mode (empty editor)
285
+ if (data === "c") {
286
+ state.editorContent = JSON.stringify(
287
+ { mcpServers: { "": { command: "", args: [], env: {} } } },
288
+ null,
289
+ 2,
290
+ );
291
+ editor.setText(state.editorContent);
292
+ state.focusPane = "editor";
293
+ state.mode = "custom";
294
+ refresh();
295
+ return;
296
+ }
297
+
298
+ // '/' or typing: start search
299
+ if (data === "/" || (data.length === 1 && data >= " ")) {
300
+ if (data === "/") {
301
+ state.searchQuery = "";
302
+ } else {
303
+ state.searchQuery += data;
304
+ }
305
+ searchServers(state.searchQuery);
306
+ refresh();
307
+ return;
308
+ }
309
+
310
+ // Backspace: delete from search
311
+ if (data === "\x7f" || data === "\b") {
312
+ if (state.searchQuery.length > 0) {
313
+ state.searchQuery = state.searchQuery.slice(0, -1);
314
+ searchServers(state.searchQuery);
315
+ refresh();
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
321
+ function render(width: number): string[] {
322
+ if (cachedLines) return cachedLines;
323
+
324
+ const lines: string[] = [];
325
+ const halfW = Math.floor(width / 2) - 1;
326
+
327
+ // Header
328
+ const header = " Add MCP Server ";
329
+ const modeLabel = state.mode === "browse" ? "[Browse]" : "[Custom]";
330
+ lines.push(
331
+ theme.accent(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
332
+ );
333
+ lines.push(
334
+ theme.accent("│") +
335
+ theme.bold(header) +
336
+ theme.muted(modeLabel.padStart(width - visibleWidth(header) - visibleWidth(modeLabel) - 1)) +
337
+ theme.accent("│"),
338
+ );
339
+ lines.push(
340
+ theme.accent(`├${"─".repeat(Math.max(0, width - 2))}┤`),
341
+ );
342
+
343
+ // Content area — split pane
344
+ const browseServers = state.filteredServers;
345
+ const maxRows = 16;
346
+
347
+ for (let row = 0; row < maxRows; row++) {
348
+ let left = "";
349
+ let right = "";
350
+
351
+ // Left pane: browse
352
+ if (row === 0) {
353
+ const searchDisplay = state.searchQuery || "search...";
354
+ left = theme.muted(` 🔍 ${searchDisplay}`);
355
+ } else if (row >= 2 && row - 2 < browseServers.length) {
356
+ const idx = row - 2;
357
+ const server = browseServers[idx];
358
+ const selected = idx === state.selectedIndex;
359
+ const scopeIcon = server.scope === "cloud" ? "☁️" : "🏠";
360
+ const prefix = selected ? theme.accent("▸ ") : " ";
361
+ const name = selected
362
+ ? theme.bold(server.name)
363
+ : theme.fg("default", server.name);
364
+ left = ` ${prefix}${scopeIcon} ${name}`;
365
+ if (selected) {
366
+ // Show description on next line if available
367
+ const desc = truncateToWidth(
368
+ server.description,
369
+ halfW - 4,
370
+ );
371
+ left = ` ${prefix}${scopeIcon} ${name}\n ${theme.muted(desc)}`;
372
+ }
373
+ }
374
+
375
+ // Right pane: editor
376
+ if (row === 0) {
377
+ right = theme.muted(" Config Editor ");
378
+ } else if (row >= 2) {
379
+ // Show editor lines
380
+ const editorLines = state.editorContent.split("\n");
381
+ const editorIdx = row - 2;
382
+ if (editorIdx < editorLines.length) {
383
+ right = theme.fg("default", truncateToWidth(editorLines[editorIdx], halfW - 3));
384
+ }
385
+ }
386
+
387
+ // Pad and combine
388
+ const leftPadded = left.padEnd(halfW);
389
+ const rightPadded = right.padEnd(halfW);
390
+ lines.push(
391
+ theme.accent("│") +
392
+ leftPadded +
393
+ theme.accent("│") +
394
+ rightPadded +
395
+ theme.accent("│"),
396
+ );
397
+ }
398
+
399
+ // Validation error
400
+ if (state.validationError) {
401
+ lines.push(
402
+ theme.accent("│") +
403
+ theme.error(` ⚠ ${truncateToWidth(state.validationError, width - 6)}`.padEnd(width - 2)) +
404
+ theme.accent("│"),
405
+ );
406
+ }
407
+
408
+ // Scope indicator
409
+ const scopeLabel = state.scope === "global" ? "Global" : "Project";
410
+ lines.push(
411
+ theme.accent(`├${"─".repeat(Math.max(0, width - 2))}┤`),
412
+ );
413
+ lines.push(
414
+ theme.accent("│") +
415
+ theme.muted(` ${scopeLabel}`.padEnd(width - 2)) +
416
+ theme.accent("│"),
417
+ );
418
+
419
+ // Keybinds
420
+ const binds = " ↑↓ navigate Enter select Tab pane c custom q/Esc close";
421
+ lines.push(
422
+ theme.accent("│") +
423
+ theme.muted(truncateToWidth(binds, width - 2).padEnd(width - 2)) +
424
+ theme.accent("│"),
425
+ );
426
+ lines.push(
427
+ theme.accent(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
428
+ );
429
+
430
+ cachedLines = lines;
431
+ return lines;
432
+ }
433
+
434
+ return { render, invalidate: refresh, handleInput };
435
+ };
436
+ }