@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,369 @@
1
+ /**
2
+ * @pi-unipi/mcp — Settings overlay TUI
3
+ *
4
+ * Interactive list of configured MCP servers with enable/disable toggle,
5
+ * edit, delete, scope switching, and sync trigger.
6
+ */
7
+
8
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
+ import type { ServerState } from "../types.js";
10
+ import {
11
+ loadMcpConfig,
12
+ saveMcpConfig,
13
+ loadMetadata,
14
+ saveMetadata,
15
+ getGlobalConfigDir,
16
+ getProjectConfigDir,
17
+ } from "../config/manager.js";
18
+
19
+ /** Server display item */
20
+ interface ServerDisplayItem {
21
+ name: string;
22
+ status: ServerState["status"];
23
+ command: string;
24
+ toolCount: number;
25
+ source: "G" | "P" | "P↑";
26
+ enabled: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ /** State for the settings overlay */
31
+ interface SettingsOverlayState {
32
+ servers: ServerDisplayItem[];
33
+ selectedIndex: number;
34
+ viewScope: "global" | "project";
35
+ confirmDelete: string | null;
36
+ }
37
+
38
+ /**
39
+ * Render the MCP settings overlay.
40
+ */
41
+ export function renderMcpSettingsOverlay(params?: {
42
+ registry?: {
43
+ getAll: () => ServerState[];
44
+ getServerState: (name: string) => ServerState | null;
45
+ startServer: (resolved: any) => Promise<void>;
46
+ stopServer: (name: string) => Promise<void>;
47
+ };
48
+ cwd?: string;
49
+ onComplete?: () => void;
50
+ }) {
51
+ return (
52
+ tui: any,
53
+ theme: any,
54
+ _kb: any,
55
+ done: (result: { action?: string } | null) => void,
56
+ ) => {
57
+ const registry = params?.registry;
58
+ const cwd = params?.cwd ?? process.cwd();
59
+
60
+ const state: SettingsOverlayState = {
61
+ servers: [],
62
+ selectedIndex: 0,
63
+ viewScope: "global",
64
+ confirmDelete: null,
65
+ };
66
+
67
+ let cachedLines: string[] | undefined;
68
+
69
+ function refreshServers() {
70
+ const configDir =
71
+ state.viewScope === "global"
72
+ ? getGlobalConfigDir()
73
+ : getProjectConfigDir(cwd);
74
+
75
+ let config;
76
+ try {
77
+ config = loadMcpConfig(configDir);
78
+ } catch {
79
+ config = { mcpServers: {} };
80
+ }
81
+
82
+ let meta;
83
+ try {
84
+ meta = loadMetadata(configDir);
85
+ } catch {
86
+ meta = { servers: {}, sync: { enabled: true, lastSyncAt: null, syncIntervalMs: 86400000 } };
87
+ }
88
+
89
+ const items: ServerDisplayItem[] = [];
90
+
91
+ for (const [name, def] of Object.entries(config.mcpServers)) {
92
+ const serverMeta = meta.servers[name];
93
+ const enabled = serverMeta?.enabled ?? true;
94
+ const runtimeState = registry?.getServerState(name);
95
+
96
+ items.push({
97
+ name,
98
+ status: runtimeState?.status ?? "stopped",
99
+ command: `${def.command} ${(def.args ?? []).slice(0, 3).join(" ")}`,
100
+ toolCount: runtimeState?.toolCount ?? 0,
101
+ source: state.viewScope === "global" ? "G" : "P",
102
+ enabled,
103
+ error: runtimeState?.error,
104
+ });
105
+ }
106
+
107
+ state.servers = items;
108
+ if (state.selectedIndex >= items.length) {
109
+ state.selectedIndex = Math.max(0, items.length - 1);
110
+ }
111
+ }
112
+
113
+ // Initial load
114
+ refreshServers();
115
+
116
+ function refresh() {
117
+ cachedLines = undefined;
118
+ tui.requestRender();
119
+ }
120
+
121
+ async function toggleServer(index: number) {
122
+ const server = state.servers[index];
123
+ if (!server) return;
124
+
125
+ const configDir =
126
+ state.viewScope === "global"
127
+ ? getGlobalConfigDir()
128
+ : getProjectConfigDir(cwd);
129
+
130
+ try {
131
+ const meta = loadMetadata(configDir);
132
+ const newEnabled = !server.enabled;
133
+
134
+ meta.servers[server.name] = {
135
+ ...(meta.servers[server.name] ?? {}),
136
+ enabled: newEnabled,
137
+ addedAt: meta.servers[server.name]?.addedAt ?? new Date().toISOString(),
138
+ };
139
+ saveMetadata(configDir, meta);
140
+
141
+ // Try to stop if disabling
142
+ if (!newEnabled && registry) {
143
+ try {
144
+ await registry.stopServer(server.name);
145
+ } catch {
146
+ // Ignore stop errors
147
+ }
148
+ }
149
+
150
+ refreshServers();
151
+ refresh();
152
+ } catch (err) {
153
+ // Silently fail
154
+ }
155
+ }
156
+
157
+ function deleteServer(name: string) {
158
+ const configDir =
159
+ state.viewScope === "global"
160
+ ? getGlobalConfigDir()
161
+ : getProjectConfigDir(cwd);
162
+
163
+ try {
164
+ const config = loadMcpConfig(configDir);
165
+ delete config.mcpServers[name];
166
+ saveMcpConfig(configDir, config);
167
+
168
+ const meta = loadMetadata(configDir);
169
+ delete meta.servers[name];
170
+ saveMetadata(configDir, meta);
171
+
172
+ refreshServers();
173
+ refresh();
174
+ } catch {
175
+ // Ignore errors
176
+ }
177
+ }
178
+
179
+ function handleInput(data: string) {
180
+ // Confirm delete mode
181
+ if (state.confirmDelete) {
182
+ if (data === "y" || data === "Y") {
183
+ deleteServer(state.confirmDelete);
184
+ state.confirmDelete = null;
185
+ refresh();
186
+ return;
187
+ }
188
+ if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
189
+ state.confirmDelete = null;
190
+ refresh();
191
+ return;
192
+ }
193
+ return;
194
+ }
195
+
196
+ // Close
197
+ if (matchesKey(data, Key.escape) || data === "q") {
198
+ done(null);
199
+ return;
200
+ }
201
+
202
+ // Navigation
203
+ if (matchesKey(data, Key.up)) {
204
+ if (state.selectedIndex > 0) {
205
+ state.selectedIndex--;
206
+ refresh();
207
+ }
208
+ return;
209
+ }
210
+
211
+ if (matchesKey(data, Key.down)) {
212
+ if (state.selectedIndex < state.servers.length - 1) {
213
+ state.selectedIndex++;
214
+ refresh();
215
+ }
216
+ return;
217
+ }
218
+
219
+ // Space: toggle enable/disable
220
+ if (data === " ") {
221
+ toggleServer(state.selectedIndex);
222
+ return;
223
+ }
224
+
225
+ // 'g': switch to global view
226
+ if (data === "g") {
227
+ state.viewScope = "global";
228
+ refreshServers();
229
+ refresh();
230
+ return;
231
+ }
232
+
233
+ // 'p': switch to project view
234
+ if (data === "p") {
235
+ state.viewScope = "project";
236
+ refreshServers();
237
+ refresh();
238
+ return;
239
+ }
240
+
241
+ // 'd': delete (with confirmation)
242
+ if (data === "d") {
243
+ const server = state.servers[state.selectedIndex];
244
+ if (server) {
245
+ state.confirmDelete = server.name;
246
+ refresh();
247
+ }
248
+ return;
249
+ }
250
+
251
+ // Enter/e: edit (placeholder)
252
+ if (data === "\r" || data === "e") {
253
+ // Edit not yet implemented in TUI — notify would need ctx
254
+ return;
255
+ }
256
+
257
+ // 'a': add (would open add overlay — placeholder)
258
+ if (data === "a") {
259
+ done({ action: "add" });
260
+ return;
261
+ }
262
+
263
+ // 's': sync
264
+ if (data === "s") {
265
+ done({ action: "sync" });
266
+ return;
267
+ }
268
+ }
269
+
270
+ function render(width: number): string[] {
271
+ if (cachedLines) return cachedLines;
272
+
273
+ const lines: string[] = [];
274
+
275
+ // Header
276
+ const header = " MCP Settings ";
277
+ const scopeLabel = state.viewScope === "global" ? "● Global" : "● Project";
278
+ lines.push(
279
+ theme.accent(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
280
+ );
281
+ lines.push(
282
+ theme.accent("│") +
283
+ theme.bold(header) +
284
+ theme.accent(
285
+ scopeLabel.padStart(width - visibleWidth(header) - visibleWidth(scopeLabel) - 1),
286
+ ) +
287
+ theme.accent("│"),
288
+ );
289
+ lines.push(
290
+ theme.accent(`├${"─".repeat(Math.max(0, width - 2))}┤`),
291
+ );
292
+
293
+ // Server list
294
+ if (state.servers.length === 0) {
295
+ lines.push(
296
+ theme.accent("│") +
297
+ theme.muted(" No servers configured".padEnd(width - 2)) +
298
+ theme.accent("│"),
299
+ );
300
+ } else {
301
+ for (let i = 0; i < state.servers.length; i++) {
302
+ const server = state.servers[i];
303
+ const selected = i === state.selectedIndex;
304
+
305
+ const statusIcon =
306
+ server.status === "running"
307
+ ? theme.success("●")
308
+ : server.status === "error"
309
+ ? theme.error("✗")
310
+ : server.enabled
311
+ ? theme.muted("○")
312
+ : theme.dim("○");
313
+
314
+ const name = selected ? theme.bold(server.name) : theme.fg("default", server.name);
315
+ const cmd = theme.muted(truncateToWidth(server.command, 24));
316
+ const tools =
317
+ server.status === "running" && server.toolCount > 0
318
+ ? theme.accent(`${server.toolCount} tools`)
319
+ : server.status === "error" && server.error
320
+ ? theme.error(truncateToWidth(server.error, 20))
321
+ : theme.dim("stopped");
322
+
323
+ const source = theme.muted(`[${server.source}]`);
324
+ const prefix = selected ? theme.accent("▸ ") : " ";
325
+
326
+ const line = ` ${prefix}${statusIcon} ${name} ${cmd} ${tools} ${source}`;
327
+ lines.push(
328
+ theme.accent("│") +
329
+ truncateToWidth(line, width - 2).padEnd(width - 2) +
330
+ theme.accent("│"),
331
+ );
332
+ }
333
+ }
334
+
335
+ // Confirm delete overlay
336
+ if (state.confirmDelete) {
337
+ lines.push(
338
+ theme.accent(`├${"─".repeat(Math.max(0, width - 2))}┤`),
339
+ );
340
+ lines.push(
341
+ theme.accent("│") +
342
+ theme.warning(
343
+ ` Delete '${state.confirmDelete}'? (y/n)`.padEnd(width - 2),
344
+ ) +
345
+ theme.accent("│"),
346
+ );
347
+ }
348
+
349
+ // Keybinds
350
+ lines.push(
351
+ theme.accent(`├${"─".repeat(Math.max(0, width - 2))}┤`),
352
+ );
353
+ const binds = " ↑↓ select Space toggle a add s sync g global p project d delete q/Esc close";
354
+ lines.push(
355
+ theme.accent("│") +
356
+ theme.muted(truncateToWidth(binds, width - 2).padEnd(width - 2)) +
357
+ theme.accent("│"),
358
+ );
359
+ lines.push(
360
+ theme.accent(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
361
+ );
362
+
363
+ cachedLines = lines;
364
+ return lines;
365
+ }
366
+
367
+ return { render, invalidate: refresh, handleInput };
368
+ };
369
+ }
package/src/types.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @pi-unipi/mcp — Type definitions
3
+ *
4
+ * All interfaces for MCP server configuration, catalog, tools, and state.
5
+ */
6
+
7
+ /** MCP server definition — standard format compatible with Claude Desktop, Cursor, etc. */
8
+ export interface McpServerDef {
9
+ /** Command to spawn the server (e.g. "npx", "docker", "node") */
10
+ command: string;
11
+ /** Arguments passed to the command */
12
+ args: string[];
13
+ /** Environment variables for the server process */
14
+ env?: Record<string, string>;
15
+ }
16
+
17
+ /** MCP configuration file format (mcp-config.json) */
18
+ export interface McpConfig {
19
+ /** Map of server name → server definition */
20
+ mcpServers: Record<string, McpServerDef>;
21
+ }
22
+
23
+ /** Per-server metadata (config.json) */
24
+ export interface ServerMeta {
25
+ /** Whether this server is enabled */
26
+ enabled: boolean;
27
+ /** ISO timestamp when server was added */
28
+ addedAt: string;
29
+ }
30
+
31
+ /** Sync configuration */
32
+ export interface SyncConfig {
33
+ /** Whether auto-sync is enabled */
34
+ enabled: boolean;
35
+ /** ISO timestamp of last successful sync */
36
+ lastSyncAt: string | null;
37
+ /** Sync interval in milliseconds */
38
+ syncIntervalMs: number;
39
+ }
40
+
41
+ /** Full metadata config (config.json) */
42
+ export interface McpMetadata {
43
+ /** Per-server metadata */
44
+ servers: Record<string, ServerMeta>;
45
+ /** Sync configuration */
46
+ sync: SyncConfig;
47
+ }
48
+
49
+ /** Per-server auth data (auth.json) */
50
+ export interface McpAuth {
51
+ /** Map of server name → environment variable key-value pairs */
52
+ [serverName: string]: Record<string, string>;
53
+ }
54
+
55
+ /** Single entry from the MCP server catalog */
56
+ export interface CatalogEntry {
57
+ /** Unique identifier (e.g. "github/github-mcp-server") */
58
+ id: string;
59
+ /** Display name */
60
+ name: string;
61
+ /** Short description */
62
+ description: string;
63
+ /** GitHub repository URL */
64
+ github: string;
65
+ /** Categories/tags */
66
+ categories: string[];
67
+ /** Primary language */
68
+ language: string;
69
+ /** Scope: "cloud" or "local" */
70
+ scope: "cloud" | "local";
71
+ /** Whether this is an official/verified server */
72
+ official: boolean;
73
+ /** Pre-filled install configuration */
74
+ install?: {
75
+ command: string;
76
+ args: string[];
77
+ envVars?: string[];
78
+ };
79
+ }
80
+
81
+ /** Cached catalog data (servers.json) */
82
+ export interface CatalogData {
83
+ /** ISO timestamp of last update */
84
+ lastUpdated: string;
85
+ /** Source identifier */
86
+ source: string;
87
+ /** Total number of servers */
88
+ totalServers: number;
89
+ /** Server entries */
90
+ servers: CatalogEntry[];
91
+ }
92
+
93
+ /** MCP tool definition (from tools/list response) */
94
+ export interface McpTool {
95
+ /** Tool name */
96
+ name: string;
97
+ /** Tool description */
98
+ description: string;
99
+ /** JSON Schema for tool input parameters */
100
+ inputSchema: Record<string, unknown>;
101
+ }
102
+
103
+ /** MCP tool call result */
104
+ export interface McpToolResult {
105
+ /** Content blocks returned by the tool */
106
+ content: Array<{
107
+ type: "text" | "image" | "resource";
108
+ text?: string;
109
+ data?: string;
110
+ mimeType?: string;
111
+ }>;
112
+ /** Whether this result is an error */
113
+ isError?: boolean;
114
+ }
115
+
116
+ /** Source of a resolved server */
117
+ export type ServerSource = "global" | "project" | "project-override";
118
+
119
+ /** A resolved server with merge metadata */
120
+ export interface ResolvedServer {
121
+ /** Server name */
122
+ name: string;
123
+ /** Server definition */
124
+ def: McpServerDef;
125
+ /** Where this server config came from */
126
+ source: ServerSource;
127
+ /** Whether the server is enabled */
128
+ enabled: boolean;
129
+ }
130
+
131
+ /** Server runtime state */
132
+ export type ServerStatus = "starting" | "running" | "error" | "stopped";
133
+
134
+ /** Runtime state of a connected server */
135
+ export interface ServerState {
136
+ /** Server name */
137
+ name: string;
138
+ /** Current status */
139
+ status: ServerStatus;
140
+ /** Process ID (if running) */
141
+ pid?: number;
142
+ /** Number of tools registered */
143
+ toolCount: number;
144
+ /** Error message (if status is "error") */
145
+ error?: string;
146
+ /** When the server was started */
147
+ startedAt?: string;
148
+ }
149
+
150
+ /** Entry in the server registry */
151
+ export interface McpRegistryEntry {
152
+ /** Server name */
153
+ name: string;
154
+ /** Resolved server config */
155
+ resolved: ResolvedServer;
156
+ /** Current state */
157
+ state: ServerState;
158
+ /** MCP client instance (if connected) */
159
+ client: unknown | null;
160
+ /** Registered tool names */
161
+ toolNames: string[];
162
+ }