@eovidiu/pi-extensions 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,242 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { MANAGED_BY, type DiscoveredMcpServer, type McpServerConfig, type PiMcpConfig, type SyncResult } from "./types.js";
5
+ import { logDebug } from "./logger.js";
6
+
7
+ export const PI_MCP_CONFIG_PATH = join(homedir(), ".pi", "mcp.json");
8
+ export const PROJECT_MCP_CONFIG_PATH = join(process.cwd(), ".pi", "mcp.json");
9
+
10
+ let mutationQueue: Promise<unknown> = Promise.resolve();
11
+
12
+ export async function readPiMcpConfig(path = PI_MCP_CONFIG_PATH): Promise<PiMcpConfig> {
13
+ try {
14
+ const text = await readFile(path, "utf8");
15
+ const parsed = JSON.parse(text) as Partial<PiMcpConfig>;
16
+ return normalizePiConfig(parsed);
17
+ } catch (error) {
18
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
19
+ return emptyConfig();
20
+ }
21
+ await logDebug("Failed to read existing Pi MCP config; using empty config", { path, error: errorMessage(error) });
22
+ return emptyConfig();
23
+ }
24
+ }
25
+
26
+ export async function syncPiMcpConfig(discovered: Record<string, DiscoveredMcpServer>, path = PI_MCP_CONFIG_PATH): Promise<SyncResult> {
27
+ return withConfigMutation(async () => {
28
+ const existing = await readPiMcpConfig(path);
29
+ const next: PiMcpConfig = {
30
+ version: 1,
31
+ autoStart: existing.autoStart === true,
32
+ servers: {},
33
+ allowServers: existing.allowServers,
34
+ denyServers: existing.denyServers,
35
+ maxOutputChars: existing.maxOutputChars,
36
+ };
37
+
38
+ const added: string[] = [];
39
+ const updated: string[] = [];
40
+ const removed: string[] = [];
41
+ const preservedManual: string[] = [];
42
+
43
+ for (const [name, server] of Object.entries(existing.servers)) {
44
+ if (server.managedBy === MANAGED_BY) {
45
+ if (!discovered[name]) removed.push(name);
46
+ continue;
47
+ }
48
+ next.servers[name] = server;
49
+ preservedManual.push(name);
50
+ }
51
+
52
+ for (const [name, server] of Object.entries(discovered)) {
53
+ const previous = existing.servers[name];
54
+ const enabled = previous?.managedBy === MANAGED_BY ? previous.enabled === true : false;
55
+ next.servers[name] = {
56
+ enabled,
57
+ managedBy: MANAGED_BY,
58
+ source: server.source,
59
+ sourceName: server.sourceName,
60
+ command: server.command,
61
+ args: server.args ?? [],
62
+ env: server.env ?? {},
63
+ };
64
+ if (previous?.managedBy === MANAGED_BY) updated.push(name);
65
+ else added.push(name);
66
+ }
67
+
68
+ await writeJsonAtomic(path, next);
69
+
70
+ const enabled = Object.entries(next.servers).filter(([, s]) => s.enabled).map(([name]) => name);
71
+ const disabled = Object.entries(next.servers).filter(([, s]) => !s.enabled).map(([name]) => name);
72
+
73
+ const result: SyncResult = {
74
+ configPath: path,
75
+ discoveredCount: Object.keys(discovered).length,
76
+ added,
77
+ updated,
78
+ removed,
79
+ preservedManual,
80
+ enabled,
81
+ disabled,
82
+ };
83
+ await logDebug("Synced Pi MCP config", result);
84
+ return result;
85
+ });
86
+ }
87
+
88
+ export interface SetServerEnabledResult {
89
+ config: PiMcpConfig;
90
+ server: McpServerConfig;
91
+ changed: boolean;
92
+ }
93
+
94
+ export async function setServerEnabled(name: string, enabled: boolean, path = PI_MCP_CONFIG_PATH): Promise<SetServerEnabledResult> {
95
+ return withConfigMutation(async () => {
96
+ const config = await readPiMcpConfig(path);
97
+ const server = getServerOrThrow(config, name);
98
+ const changed = server.enabled !== enabled;
99
+ config.servers[name] = { ...server, enabled };
100
+ await writeJsonAtomic(path, config);
101
+ await logDebug(enabled ? "Enabled MCP server" : "Disabled MCP server", { name, changed });
102
+ return { config, server: config.servers[name], changed };
103
+ });
104
+ }
105
+
106
+ export function getServerOrThrow(config: PiMcpConfig, name: string): McpServerConfig {
107
+ validateServerName(name);
108
+ const server = config.servers[name];
109
+ if (!server) {
110
+ const suggestions = suggestServerNames(config, name);
111
+ const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
112
+ throw new Error(`Unknown MCP server: ${name}.${hint}`);
113
+ }
114
+ return server;
115
+ }
116
+
117
+ export function listServerNames(config: PiMcpConfig): string[] {
118
+ return Object.keys(config.servers).sort();
119
+ }
120
+
121
+ export function listEnabledServerNames(config: PiMcpConfig): string[] {
122
+ return listServerNames(config).filter((name) => config.servers[name].enabled);
123
+ }
124
+
125
+ export function listDisabledServerNames(config: PiMcpConfig): string[] {
126
+ return listServerNames(config).filter((name) => !config.servers[name].enabled);
127
+ }
128
+
129
+ export function validateServerName(name: string): void {
130
+ if (!name.trim()) throw new Error("MCP server name is required.");
131
+ if (/\s/.test(name)) throw new Error(`Invalid MCP server name: ${name}. Server names cannot contain whitespace.`);
132
+ if (!/^[A-Za-z0-9_][-A-Za-z0-9_.:]*$/.test(name)) {
133
+ throw new Error(`Invalid MCP server name: ${name}.`);
134
+ }
135
+ }
136
+
137
+ export async function readEffectivePiMcpConfig(homePath = PI_MCP_CONFIG_PATH, projectPath = PROJECT_MCP_CONFIG_PATH): Promise<PiMcpConfig> {
138
+ const home = await readPiMcpConfig(homePath);
139
+ if (projectPath === homePath) return home;
140
+ let project: PiMcpConfig | null = null;
141
+ try {
142
+ const text = await readFile(projectPath, "utf8");
143
+ project = normalizePiConfig(JSON.parse(text) as Partial<PiMcpConfig>);
144
+ } catch (error) {
145
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
146
+ await logDebug("Failed to read project Pi MCP config; ignoring project overrides", { projectPath, error: errorMessage(error) });
147
+ }
148
+ }
149
+ if (!project) return home;
150
+ return {
151
+ version: 1,
152
+ autoStart: project.autoStart || home.autoStart,
153
+ allowServers: project.allowServers ?? home.allowServers,
154
+ denyServers: project.denyServers ?? home.denyServers,
155
+ maxOutputChars: project.maxOutputChars ?? home.maxOutputChars,
156
+ servers: { ...home.servers, ...project.servers },
157
+ };
158
+ }
159
+
160
+ export function summarizeConfig(config: PiMcpConfig): string {
161
+ const names = listServerNames(config);
162
+ if (names.length === 0) return "No MCP servers configured.";
163
+ return names
164
+ .map((name) => {
165
+ const server = config.servers[name];
166
+ const source = server.source ? ` source=${server.source}` : "";
167
+ const managed = server.managedBy === MANAGED_BY ? "managed" : "manual";
168
+ return `${server.enabled ? "enabled " : "disabled"} ${name} [${managed}${source}] command=${server.command}`;
169
+ })
170
+ .join("\n");
171
+ }
172
+
173
+ function suggestServerNames(config: PiMcpConfig, input: string): string[] {
174
+ const normalizedInput = input.toLowerCase();
175
+ return listServerNames(config)
176
+ .filter((name) => name.toLowerCase().includes(normalizedInput) || normalizedInput.includes(name.toLowerCase()))
177
+ .slice(0, 5);
178
+ }
179
+
180
+ async function withConfigMutation<T>(fn: () => Promise<T>): Promise<T> {
181
+ const run = mutationQueue.then(fn, fn);
182
+ mutationQueue = run.catch(() => undefined);
183
+ return run;
184
+ }
185
+
186
+ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
187
+ await mkdir(dirname(path), { recursive: true });
188
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
189
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
190
+ await rename(tmp, path);
191
+ }
192
+
193
+ function normalizePiConfig(parsed: Partial<PiMcpConfig>): PiMcpConfig {
194
+ const servers: Record<string, McpServerConfig> = {};
195
+ const rawServers = parsed.servers && typeof parsed.servers === "object" ? parsed.servers : {};
196
+ for (const [name, raw] of Object.entries(rawServers)) {
197
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
198
+ const obj = raw as Partial<McpServerConfig>;
199
+ if (typeof obj.command !== "string") continue;
200
+ servers[name] = {
201
+ enabled: obj.enabled === true,
202
+ managedBy: typeof obj.managedBy === "string" ? obj.managedBy : undefined,
203
+ source: typeof obj.source === "string" ? obj.source : undefined,
204
+ sourceName: typeof obj.sourceName === "string" ? obj.sourceName : undefined,
205
+ command: obj.command,
206
+ args: Array.isArray(obj.args) ? obj.args.filter((arg): arg is string => typeof arg === "string") : [],
207
+ env: normalizeEnv(obj.env),
208
+ };
209
+ }
210
+ return {
211
+ version: 1,
212
+ autoStart: parsed.autoStart === true,
213
+ allowServers: normalizeStringArray(parsed.allowServers),
214
+ denyServers: normalizeStringArray(parsed.denyServers),
215
+ maxOutputChars: typeof parsed.maxOutputChars === "number" && parsed.maxOutputChars > 0 ? Math.floor(parsed.maxOutputChars) : undefined,
216
+ servers,
217
+ };
218
+ }
219
+
220
+ function emptyConfig(): PiMcpConfig {
221
+ return { version: 1, autoStart: false, servers: {} };
222
+ }
223
+
224
+ function normalizeStringArray(raw: unknown): string[] | undefined {
225
+ if (!Array.isArray(raw)) return undefined;
226
+ const values = raw.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
227
+ return values.length ? values : undefined;
228
+ }
229
+
230
+ function normalizeEnv(raw: unknown): Record<string, string> {
231
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
232
+ const out: Record<string, string> = {};
233
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
234
+ if (typeof value === "string") out[key] = value;
235
+ else if (typeof value === "number" || typeof value === "boolean") out[key] = String(value);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function errorMessage(error: unknown): string {
241
+ return error instanceof Error ? error.message : String(error);
242
+ }
@@ -0,0 +1,354 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { discoverMcpServers } from "./config-discovery.js";
3
+ import {
4
+ getServerOrThrow,
5
+ listDisabledServerNames,
6
+ listEnabledServerNames,
7
+ listServerNames,
8
+ PI_MCP_CONFIG_PATH,
9
+ PROJECT_MCP_CONFIG_PATH,
10
+ readEffectivePiMcpConfig,
11
+ readPiMcpConfig,
12
+ setServerEnabled,
13
+ summarizeConfig,
14
+ syncPiMcpConfig,
15
+ validateServerName,
16
+ } from "./config-sync.js";
17
+ import { getLogPath, logDebug } from "./logger.js";
18
+ import { McpBridgeRuntime } from "./mcp-client.js";
19
+ import { McpToolRegistrar } from "./tool-registration.js";
20
+
21
+ export default function mcpSyncBridge(pi: ExtensionAPI) {
22
+ const runtime = new McpBridgeRuntime();
23
+ const registrar = new McpToolRegistrar(pi, runtime);
24
+
25
+ pi.on("session_start", async (_event, ctx) => {
26
+ try {
27
+ const result = await runSync();
28
+ const config = await readEffectivePiMcpConfig();
29
+ const registered = await startEnabledAndRegister(runtime, registrar, config);
30
+ notify(ctx, [
31
+ `MCP sync complete: ${result.discoveredCount} discovered, ${result.added.length} added, ${result.removed.length} removed.`,
32
+ "New servers are disabled by default.",
33
+ `Enabled MCP tools active: ${registered.length}`,
34
+ ].join("\n"));
35
+ } catch (error) {
36
+ await logDebug("MCP startup failed on session_start", { error: errorMessage(error) });
37
+ notify(ctx, `MCP startup failed. See ${getLogPath()}`, "error");
38
+ }
39
+ });
40
+
41
+ pi.on("session_shutdown", async () => {
42
+ registrar.deactivateAll();
43
+ await runtime.stopAll();
44
+ });
45
+
46
+ pi.registerCommand("mcp-sync", {
47
+ description: "Rescan Claude/Codex MCP configs and update ~/.pi/mcp.json without enabling new servers",
48
+ handler: async (_args, ctx) => {
49
+ try {
50
+ const result = await runSync();
51
+ const config = await readEffectivePiMcpConfig();
52
+ const registered = await startEnabledAndRegister(runtime, registrar, config);
53
+ notify(ctx, [
54
+ `MCP sync complete. Config: ${result.configPath}`,
55
+ `Discovered: ${result.discoveredCount}`,
56
+ `Added: ${result.added.length ? result.added.join(", ") : "none"}`,
57
+ `Updated: ${result.updated.length}`,
58
+ `Removed: ${result.removed.length ? result.removed.join(", ") : "none"}`,
59
+ `Enabled servers: ${result.enabled.length}`,
60
+ `Disabled servers: ${result.disabled.length}`,
61
+ `Active MCP tools: ${registered.length}`,
62
+ `Log: ${getLogPath()}`,
63
+ ].join("\n"));
64
+ } catch (error) {
65
+ await logDebug("/mcp-sync failed", { error: errorMessage(error) });
66
+ notify(ctx, `MCP sync failed. See ${getLogPath()}`, "error");
67
+ }
68
+ },
69
+ });
70
+
71
+ pi.registerCommand("mcp-status", {
72
+ description: "Show MCP servers tracked by the Pi MCP sync bridge",
73
+ handler: async (_args, ctx) => {
74
+ try {
75
+ const config = await readEffectivePiMcpConfig();
76
+ const enabled = listEnabledServerNames(config);
77
+ notify(ctx, [
78
+ `Pi MCP config: ${PI_MCP_CONFIG_PATH}`,
79
+ `Project override: ${PROJECT_MCP_CONFIG_PATH}`,
80
+ summarizeConfig(config),
81
+ "",
82
+ `Enabled servers: ${enabled.length ? enabled.join(", ") : "none"}`,
83
+ runtime.getConnectionSummary(),
84
+ `Log: ${getLogPath()}`,
85
+ ].join("\n"));
86
+ } catch (error) {
87
+ await logDebug("/mcp-status failed", { error: errorMessage(error) });
88
+ notify(ctx, `MCP status failed. See ${getLogPath()}`, "error");
89
+ }
90
+ },
91
+ });
92
+
93
+ pi.registerCommand("mcp-enable", {
94
+ description: "Enable and start one or more disabled MCP servers",
95
+ getArgumentCompletions: serverNameCompletions,
96
+ handler: async (args, ctx) => {
97
+ const name = args.trim();
98
+ if (name) {
99
+ const parsed = parseServerArg(args, "/mcp-enable [server]");
100
+ if (!parsed.ok) {
101
+ notify(ctx, parsed.message, "error");
102
+ return;
103
+ }
104
+ try {
105
+ const result = await setServerEnabled(parsed.name, true);
106
+ const config = await readEffectivePiMcpConfig();
107
+ const registered = await startEnabledAndRegister(runtime, registrar, config);
108
+ const status = result.changed ? "Enabled" : "Already enabled";
109
+ notify(ctx, `${status} ${parsed.name}. Active MCP tools: ${registered.length}.`);
110
+ } catch (error) {
111
+ await logDebug("/mcp-enable failed", { name: parsed.name, error: errorMessage(error) });
112
+ notify(ctx, errorMessage(error), "error");
113
+ }
114
+ return;
115
+ }
116
+
117
+ try {
118
+ const sync = await runSync();
119
+ const homeConfig = await readPiMcpConfig();
120
+ const disabled = listDisabledServerNames(homeConfig);
121
+ if (disabled.length === 0) {
122
+ notify(ctx, `No disabled MCP servers found. Sync complete: ${sync.discoveredCount} discovered.`);
123
+ return;
124
+ }
125
+
126
+ if (!hasSelectableUi(ctx)) {
127
+ notify(ctx, [`Usage: /mcp-enable <server>`, `Disabled servers: ${disabled.join(", ")}`].join("\n"));
128
+ return;
129
+ }
130
+
131
+ const selected = await selectDisabledServersToEnable(ctx, homeConfig, disabled);
132
+ if (selected.length === 0) {
133
+ notify(ctx, "No MCP servers enabled.");
134
+ return;
135
+ }
136
+
137
+ const changed: string[] = [];
138
+ const alreadyEnabled: string[] = [];
139
+ for (const serverName of selected) {
140
+ const result = await setServerEnabled(serverName, true);
141
+ if (result.changed) changed.push(serverName);
142
+ else alreadyEnabled.push(serverName);
143
+ }
144
+
145
+ const config = await readEffectivePiMcpConfig();
146
+ const registered = await startEnabledAndRegister(runtime, registrar, config);
147
+ notify(ctx, [
148
+ `Enabled MCP servers: ${changed.length ? changed.join(", ") : "none"}`,
149
+ alreadyEnabled.length ? `Already enabled: ${alreadyEnabled.join(", ")}` : undefined,
150
+ `Active MCP tools: ${registered.length}`,
151
+ ].filter((line): line is string => Boolean(line)).join("\n"));
152
+ } catch (error) {
153
+ await logDebug("/mcp-enable failed", { error: errorMessage(error) });
154
+ notify(ctx, errorMessage(error), "error");
155
+ }
156
+ },
157
+ });
158
+
159
+ pi.registerCommand("mcp-disable", {
160
+ description: "Disable and stop one or more enabled MCP servers",
161
+ getArgumentCompletions: serverNameCompletions,
162
+ handler: async (args, ctx) => {
163
+ const name = args.trim();
164
+ if (name) {
165
+ const parsed = parseServerArg(args, "/mcp-disable [server]");
166
+ if (!parsed.ok) {
167
+ notify(ctx, parsed.message, "error");
168
+ return;
169
+ }
170
+ try {
171
+ const result = await setServerEnabled(parsed.name, false);
172
+ const removed = await runtime.stopServer(parsed.name);
173
+ registrar.deactivateTools(removed);
174
+ const status = result.changed ? "Disabled" : "Already disabled";
175
+ notify(ctx, `${status} ${parsed.name}. Deactivated MCP tools: ${removed.length}.`);
176
+ } catch (error) {
177
+ await logDebug("/mcp-disable failed", { name: parsed.name, error: errorMessage(error) });
178
+ notify(ctx, errorMessage(error), "error");
179
+ }
180
+ return;
181
+ }
182
+
183
+ try {
184
+ const config = await readEffectivePiMcpConfig();
185
+ const enabled = listEnabledServerNames(config);
186
+ if (enabled.length === 0) {
187
+ notify(ctx, "No enabled MCP servers found.");
188
+ return;
189
+ }
190
+
191
+ if (!hasSelectableUi(ctx)) {
192
+ notify(ctx, [`Usage: /mcp-disable <server>`, `Enabled servers: ${enabled.join(", ")}`].join("\n"));
193
+ return;
194
+ }
195
+
196
+ const selected = await selectServersToDisable(ctx, config, enabled);
197
+ if (selected.length === 0) {
198
+ notify(ctx, "No MCP servers disabled.");
199
+ return;
200
+ }
201
+
202
+ const changed: string[] = [];
203
+ const alreadyDisabled: string[] = [];
204
+ let deactivatedTools = 0;
205
+ for (const serverName of selected) {
206
+ const result = await setServerEnabled(serverName, false);
207
+ if (result.changed) changed.push(serverName);
208
+ else alreadyDisabled.push(serverName);
209
+ const removed = await runtime.stopServer(serverName);
210
+ registrar.deactivateTools(removed);
211
+ deactivatedTools += removed.length;
212
+ }
213
+
214
+ notify(ctx, [
215
+ `Disabled MCP servers: ${changed.length ? changed.join(", ") : "none"}`,
216
+ alreadyDisabled.length ? `Already disabled: ${alreadyDisabled.join(", ")}` : undefined,
217
+ `Deactivated MCP tools: ${deactivatedTools}`,
218
+ ].filter((line): line is string => Boolean(line)).join("\n"));
219
+ } catch (error) {
220
+ await logDebug("/mcp-disable failed", { error: errorMessage(error) });
221
+ notify(ctx, errorMessage(error), "error");
222
+ }
223
+ },
224
+ });
225
+
226
+ pi.registerCommand("mcp-restart", {
227
+ description: "Restart one enabled MCP server or all enabled MCP servers",
228
+ getArgumentCompletions: serverNameCompletions,
229
+ handler: async (args, ctx) => {
230
+ try {
231
+ const config = await readEffectivePiMcpConfig();
232
+ const name = args.trim();
233
+ if (name) {
234
+ validateServerName(name);
235
+ getServerOrThrow(config, name);
236
+ const removed = await runtime.stopServer(name);
237
+ registrar.deactivateTools(removed);
238
+ await runtime.restartServer(name, config);
239
+ const active = await registrar.registerBindings(runtime.getToolBindings());
240
+ notify(ctx, `Restarted ${name}. Active MCP tools: ${active.length}.`);
241
+ return;
242
+ }
243
+
244
+ const enabled = listEnabledServerNames(config);
245
+ const removed = await runtime.stopAll();
246
+ registrar.deactivateTools(removed);
247
+ const active = await startEnabledAndRegister(runtime, registrar, config);
248
+ notify(ctx, enabled.length
249
+ ? `Restarted enabled MCP servers: ${enabled.join(", ")}. Active MCP tools: ${active.length}.`
250
+ : "No enabled MCP servers to restart.");
251
+ } catch (error) {
252
+ await logDebug("/mcp-restart failed", { args, error: errorMessage(error) });
253
+ notify(ctx, errorMessage(error), "error");
254
+ }
255
+ },
256
+ });
257
+ }
258
+
259
+ async function runSync() {
260
+ const discovery = await discoverMcpServers();
261
+ await logDebug("MCP discovery complete", discovery.events);
262
+ return syncPiMcpConfig(discovery.servers);
263
+ }
264
+
265
+ async function startEnabledAndRegister(runtime: McpBridgeRuntime, registrar: McpToolRegistrar, config: Awaited<ReturnType<typeof readPiMcpConfig>>): Promise<string[]> {
266
+ registrar.setMaxOutputChars(config.maxOutputChars);
267
+ const removed = await runtime.stopNotEnabled(config);
268
+ registrar.deactivateTools(removed);
269
+ const bindings = await runtime.startEnabled(config);
270
+ return registrar.registerBindings(bindings);
271
+ }
272
+
273
+ async function serverNameCompletions(prefix: string) {
274
+ const config = await readEffectivePiMcpConfig();
275
+ const lower = prefix.toLowerCase();
276
+ const items = listServerNames(config)
277
+ .filter((name) => name.toLowerCase().startsWith(lower) || name.toLowerCase().includes(lower))
278
+ .map((name) => ({ value: name, label: `${name}${config.servers[name].enabled ? " (enabled)" : " (disabled)"}` }));
279
+ return items.length ? items : null;
280
+ }
281
+
282
+ async function selectDisabledServersToEnable(ctx: SelectableContext, config: Awaited<ReturnType<typeof readPiMcpConfig>>, disabled: string[]): Promise<string[]> {
283
+ return selectServers(ctx, config, disabled, "Select MCP servers to enable", "Enable selected");
284
+ }
285
+
286
+ async function selectServersToDisable(ctx: SelectableContext, config: Awaited<ReturnType<typeof readPiMcpConfig>>, enabled: string[]): Promise<string[]> {
287
+ return selectServers(ctx, config, enabled, "Select MCP servers to disable", "Disable selected");
288
+ }
289
+
290
+ async function selectServers(ctx: SelectableContext, config: Awaited<ReturnType<typeof readPiMcpConfig>>, names: string[], title: string, actionLabel: string): Promise<string[]> {
291
+ const selected = new Set<string>();
292
+
293
+ while (true) {
294
+ const options = [
295
+ ...names.map((name) => formatServerSelectionOption(name, config, selected.has(name))),
296
+ selected.size > 0 ? `${actionLabel} (${selected.size})` : actionLabel,
297
+ "Cancel",
298
+ ];
299
+
300
+ const choice = await ctx.ui.select(title, options);
301
+ if (!choice || choice === "Cancel") return [];
302
+ if (choice.startsWith(actionLabel)) return [...selected].sort();
303
+
304
+ const serverName = parseServerNameFromSelection(choice);
305
+ if (!serverName) continue;
306
+ if (selected.has(serverName)) selected.delete(serverName);
307
+ else selected.add(serverName);
308
+ }
309
+ }
310
+
311
+ function formatServerSelectionOption(name: string, config: Awaited<ReturnType<typeof readPiMcpConfig>>, selected: boolean): string {
312
+ const server = config.servers[name];
313
+ const source = server.source ? ` source=${server.source}` : "";
314
+ return `${selected ? "[✓]" : "[ ]"} ${name}${source} command=${server.command}`;
315
+ }
316
+
317
+ function parseServerNameFromSelection(choice: string): string | null {
318
+ const match = /^\[[ ✓]\] (\S+)/.exec(choice);
319
+ return match?.[1] ?? null;
320
+ }
321
+
322
+ interface SelectableContext {
323
+ ui: {
324
+ select(title: string, options: string[]): Promise<string | undefined>;
325
+ };
326
+ }
327
+
328
+ function hasSelectableUi(ctx: unknown): ctx is SelectableContext {
329
+ const maybeCtx = ctx as { hasUI?: boolean; ui?: { select?: unknown } };
330
+ return maybeCtx.hasUI !== false && typeof maybeCtx.ui?.select === "function";
331
+ }
332
+
333
+ function parseServerArg(args: string, usage: string): { ok: true; name: string } | { ok: false; message: string } {
334
+ const trimmed = args.trim();
335
+ if (!trimmed) return { ok: false, message: `Usage: ${usage}` };
336
+ const parts = trimmed.split(/\s+/);
337
+ if (parts.length !== 1) return { ok: false, message: `Usage: ${usage}` };
338
+ try {
339
+ validateServerName(parts[0]);
340
+ return { ok: true, name: parts[0] };
341
+ } catch (error) {
342
+ return { ok: false, message: errorMessage(error) };
343
+ }
344
+ }
345
+
346
+ function notify(ctx: unknown, message: string, level: "info" | "warn" | "error" = "info") {
347
+ const maybeCtx = ctx as { hasUI?: boolean; ui?: { notify?: (message: string, level?: string) => void } };
348
+ if (maybeCtx.hasUI === false) return;
349
+ maybeCtx.ui?.notify?.(message, level);
350
+ }
351
+
352
+ function errorMessage(error: unknown): string {
353
+ return error instanceof Error ? error.message : String(error);
354
+ }
@@ -0,0 +1,35 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const SECRET_KEY = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTH|CREDENTIAL|PRIVATE)/i;
6
+ const LOG_PATH = join(homedir(), ".pi", "mcp-bridge.log");
7
+
8
+ export function getLogPath(): string {
9
+ return LOG_PATH;
10
+ }
11
+
12
+ export function redact(value: unknown): unknown {
13
+ if (Array.isArray(value)) return value.map((item) => redact(item));
14
+ if (value && typeof value === "object") {
15
+ const out: Record<string, unknown> = {};
16
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
17
+ out[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redact(child);
18
+ }
19
+ return out;
20
+ }
21
+ if (typeof value === "string") {
22
+ return value.replace(/(token|key|secret|password|auth|credential)=([^\s&]+)/gi, "$1=[REDACTED]");
23
+ }
24
+ return value;
25
+ }
26
+
27
+ export async function logDebug(message: string, details?: unknown): Promise<void> {
28
+ await mkdir(dirname(LOG_PATH), { recursive: true });
29
+ const entry = {
30
+ ts: new Date().toISOString(),
31
+ message,
32
+ details: details === undefined ? undefined : redact(details),
33
+ };
34
+ await appendFile(LOG_PATH, `${JSON.stringify(entry)}\n`, "utf8");
35
+ }