@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.
- package/README.md +109 -0
- package/data/seed-servers.json +727 -0
- package/package.json +47 -0
- package/skills/mcp/SKILL.md +104 -0
- package/src/bridge/client.ts +365 -0
- package/src/bridge/registry.ts +281 -0
- package/src/bridge/translator.ts +100 -0
- package/src/config/manager.ts +267 -0
- package/src/config/schema.ts +114 -0
- package/src/config/sync.ts +416 -0
- package/src/index.ts +297 -0
- package/src/tui/add-overlay.ts +436 -0
- package/src/tui/settings-overlay.ts +369 -0
- package/src/types.ts +162 -0
|
@@ -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
|
+
}
|