omegon 0.6.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/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
// @secret GITHUB_TOKEN "GitHub personal access token for MCP server auth (Scribe, etc.)"
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import {
|
|
12
|
+
type ServerConfig,
|
|
13
|
+
type HttpServerConfig,
|
|
14
|
+
type StdioServerConfig,
|
|
15
|
+
type ConfigSource,
|
|
16
|
+
type SourcedConfig,
|
|
17
|
+
isHttpConfig,
|
|
18
|
+
resolveEnvVars,
|
|
19
|
+
resolveEnvObj,
|
|
20
|
+
isAuthError,
|
|
21
|
+
isTransportError,
|
|
22
|
+
extractText,
|
|
23
|
+
loadMergedConfig,
|
|
24
|
+
configFileForScope,
|
|
25
|
+
slugifyUrl,
|
|
26
|
+
buildHttpConfig,
|
|
27
|
+
buildStdioConfig,
|
|
28
|
+
parseCommand,
|
|
29
|
+
extractSecretRefs,
|
|
30
|
+
AUTH_REMEDIATION,
|
|
31
|
+
} from "./lib.ts";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Runtime types
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
interface ConnectedServer {
|
|
38
|
+
name: string;
|
|
39
|
+
client: Client;
|
|
40
|
+
transport: StdioClientTransport | StreamableHTTPClientTransport;
|
|
41
|
+
config: ServerConfig;
|
|
42
|
+
tools: Array<{ name: string; description?: string; inputSchema: any }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
46
|
+
const USER_DIR = path.join(homedir(), ".pi", "agent");
|
|
47
|
+
const EXTENSION_DIR = import.meta.dirname;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Extension
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export default async function (pi: ExtensionAPI) {
|
|
54
|
+
const servers: Record<string, ConnectedServer> = {};
|
|
55
|
+
|
|
56
|
+
// Track connection outcomes for session_start notification
|
|
57
|
+
const connectionErrors: Array<{ name: string; message: string }> = [];
|
|
58
|
+
let totalTools = 0;
|
|
59
|
+
|
|
60
|
+
// Resolved config with source tracking
|
|
61
|
+
let configResult: SourcedConfig;
|
|
62
|
+
|
|
63
|
+
// In-flight reconnect promises, keyed by server name. Prevents concurrent
|
|
64
|
+
// reconnect attempts from racing and leaking duplicate connections.
|
|
65
|
+
const reconnecting = new Map<string, Promise<ConnectedServer | null>>();
|
|
66
|
+
|
|
67
|
+
// ── Timeout helper ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Race a promise against a deadline. On timeout, attempts to close the
|
|
71
|
+
* transport to avoid leaking child processes or HTTP connections.
|
|
72
|
+
*/
|
|
73
|
+
function withTimeout(
|
|
74
|
+
promise: Promise<ConnectedServer>,
|
|
75
|
+
ms: number,
|
|
76
|
+
label: string
|
|
77
|
+
): Promise<ConnectedServer> {
|
|
78
|
+
let settled = false;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
reject(new Error(`[mcp-bridge] ${label}: timed out after ${ms}ms`));
|
|
84
|
+
// Best-effort cleanup: the inner promise may still resolve with a
|
|
85
|
+
// ConnectedServer whose transport is alive. Close it.
|
|
86
|
+
promise.then(
|
|
87
|
+
(s) => { try { s.transport.close(); } catch {} },
|
|
88
|
+
() => {} // inner already failed, nothing to clean up
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}, ms);
|
|
92
|
+
promise.then(
|
|
93
|
+
(v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } },
|
|
94
|
+
(e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e); } }
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Server connection ───────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
async function connectStdioServer(
|
|
102
|
+
name: string,
|
|
103
|
+
config: StdioServerConfig
|
|
104
|
+
): Promise<ConnectedServer> {
|
|
105
|
+
const resolvedEnv = config.env ? resolveEnvObj(config.env) : {};
|
|
106
|
+
|
|
107
|
+
const transport = new StdioClientTransport({
|
|
108
|
+
command: config.command,
|
|
109
|
+
args: config.args ?? [],
|
|
110
|
+
env: { ...process.env, ...resolvedEnv } as Record<string, string>,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const client = new Client({
|
|
114
|
+
name: `pi-mcp-bridge/${name}`,
|
|
115
|
+
version: "1.0.0",
|
|
116
|
+
});
|
|
117
|
+
await client.connect(transport);
|
|
118
|
+
const { tools } = await client.listTools();
|
|
119
|
+
|
|
120
|
+
return { name, client, transport, config, tools };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function connectHttpServer(
|
|
124
|
+
name: string,
|
|
125
|
+
config: HttpServerConfig
|
|
126
|
+
): Promise<ConnectedServer> {
|
|
127
|
+
const resolvedHeaders = config.headers
|
|
128
|
+
? resolveEnvObj(config.headers)
|
|
129
|
+
: {};
|
|
130
|
+
|
|
131
|
+
const transport = new StreamableHTTPClientTransport(
|
|
132
|
+
new URL(resolveEnvVars(config.url)),
|
|
133
|
+
{
|
|
134
|
+
requestInit: {
|
|
135
|
+
headers: resolvedHeaders,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const client = new Client({
|
|
141
|
+
name: `pi-mcp-bridge/${name}`,
|
|
142
|
+
version: "1.0.0",
|
|
143
|
+
});
|
|
144
|
+
await client.connect(transport);
|
|
145
|
+
const { tools } = await client.listTools();
|
|
146
|
+
|
|
147
|
+
return { name, client, transport, config, tools };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function connectServer(
|
|
151
|
+
name: string,
|
|
152
|
+
config: ServerConfig
|
|
153
|
+
): Promise<ConnectedServer> {
|
|
154
|
+
const timeoutMs = isHttpConfig(config)
|
|
155
|
+
? config.timeout ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
156
|
+
: DEFAULT_CONNECT_TIMEOUT_MS;
|
|
157
|
+
|
|
158
|
+
const inner = isHttpConfig(config)
|
|
159
|
+
? connectHttpServer(name, config)
|
|
160
|
+
: connectStdioServer(name, config as StdioServerConfig);
|
|
161
|
+
|
|
162
|
+
return withTimeout(inner, timeoutMs, name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Reconnection ───────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Reconnect a server, deduplicating concurrent attempts. If a reconnect
|
|
169
|
+
* is already in flight for this server, returns the existing promise.
|
|
170
|
+
*/
|
|
171
|
+
function reconnectServer(
|
|
172
|
+
name: string,
|
|
173
|
+
config: ServerConfig
|
|
174
|
+
): Promise<ConnectedServer | null> {
|
|
175
|
+
const inflight = reconnecting.get(name);
|
|
176
|
+
if (inflight) return inflight;
|
|
177
|
+
|
|
178
|
+
const attempt = (async (): Promise<ConnectedServer | null> => {
|
|
179
|
+
// Tear down old connection
|
|
180
|
+
const old = servers[name];
|
|
181
|
+
if (old) {
|
|
182
|
+
try { await old.client.close(); } catch {}
|
|
183
|
+
delete servers[name];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const fresh = await connectServer(name, config);
|
|
188
|
+
servers[name] = fresh;
|
|
189
|
+
return fresh;
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
console.error(`[mcp-bridge] Reconnect failed for ${name}: ${err.message}`);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
|
|
196
|
+
// Clear the mutex when done regardless of outcome
|
|
197
|
+
attempt.finally(() => reconnecting.delete(name));
|
|
198
|
+
reconnecting.set(name, attempt);
|
|
199
|
+
|
|
200
|
+
return attempt;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Tool registration ──────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function jsonSchemaToTypebox(schema: any): any {
|
|
206
|
+
if (!schema || typeof schema !== "object") return Type.Object({});
|
|
207
|
+
return Type.Unsafe(schema);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function registerToolsForServer(server: ConnectedServer): number {
|
|
211
|
+
const serverName = server.name;
|
|
212
|
+
const serverConfig = server.config;
|
|
213
|
+
let count = 0;
|
|
214
|
+
|
|
215
|
+
for (const tool of server.tools) {
|
|
216
|
+
const toolName = tool.name;
|
|
217
|
+
const piToolName = `mcp_${serverName}_${toolName}`;
|
|
218
|
+
|
|
219
|
+
pi.registerTool({
|
|
220
|
+
name: piToolName,
|
|
221
|
+
label: `${serverName}/${toolName}`,
|
|
222
|
+
description: tool.description ?? `MCP tool from ${serverName}`,
|
|
223
|
+
parameters: jsonSchemaToTypebox(tool.inputSchema),
|
|
224
|
+
|
|
225
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
226
|
+
// Always read current server — may have been replaced by reconnect
|
|
227
|
+
const current = servers[serverName];
|
|
228
|
+
if (!current) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: `Error: server ${serverName} is not connected` }],
|
|
231
|
+
details: { server: serverName, tool: toolName, error: true },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const result = await current.client.callTool({
|
|
237
|
+
name: toolName,
|
|
238
|
+
arguments: params,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
content: [{ type: "text", text: extractText(result) }],
|
|
243
|
+
details: { server: serverName, tool: toolName },
|
|
244
|
+
};
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
// Auth errors — reconnecting won't help
|
|
247
|
+
if (isAuthError(err)) {
|
|
248
|
+
return {
|
|
249
|
+
content: [
|
|
250
|
+
{
|
|
251
|
+
type: "text",
|
|
252
|
+
text: `[mcp-bridge] ${serverName}: authentication failed.\n${AUTH_REMEDIATION}`,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
details: { server: serverName, tool: toolName, error: true, auth: true },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Transport errors — attempt one reconnect + retry
|
|
260
|
+
if (isTransportError(err)) {
|
|
261
|
+
const reconnected = await reconnectServer(serverName, serverConfig);
|
|
262
|
+
if (reconnected) {
|
|
263
|
+
try {
|
|
264
|
+
const retry = await reconnected.client.callTool({
|
|
265
|
+
name: toolName,
|
|
266
|
+
arguments: params,
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: "text", text: extractText(retry) }],
|
|
270
|
+
details: { server: serverName, tool: toolName, reconnected: true },
|
|
271
|
+
};
|
|
272
|
+
} catch (retryErr: any) {
|
|
273
|
+
const msg = isAuthError(retryErr)
|
|
274
|
+
? `[mcp-bridge] ${serverName}: authentication failed.\n${AUTH_REMEDIATION}`
|
|
275
|
+
: `Error after reconnect: ${retryErr.message}`;
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text: msg }],
|
|
278
|
+
details: {
|
|
279
|
+
server: serverName,
|
|
280
|
+
tool: toolName,
|
|
281
|
+
error: true,
|
|
282
|
+
...(isAuthError(retryErr) && { auth: true }),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: "text", text: `Error: ${err.message}` }],
|
|
291
|
+
details: { server: serverName, tool: toolName, error: true },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
count++;
|
|
298
|
+
}
|
|
299
|
+
return count;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Config file helpers ─────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read an mcp.json file, returning its parsed content or a fresh skeleton.
|
|
306
|
+
*/
|
|
307
|
+
function readConfigFile(filePath: string): any {
|
|
308
|
+
if (fs.existsSync(filePath)) {
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
311
|
+
} catch {
|
|
312
|
+
return { servers: {} };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { servers: {} };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Write a server entry to the appropriate mcp.json.
|
|
320
|
+
*/
|
|
321
|
+
function writeServerToConfig(
|
|
322
|
+
scope: "project" | "user",
|
|
323
|
+
serverName: string,
|
|
324
|
+
config: ServerConfig,
|
|
325
|
+
): string | null {
|
|
326
|
+
const projectDir = process.cwd();
|
|
327
|
+
const filePath = configFileForScope(scope, projectDir, USER_DIR);
|
|
328
|
+
if (!filePath) return null;
|
|
329
|
+
|
|
330
|
+
// Ensure parent directory exists
|
|
331
|
+
const dir = path.dirname(filePath);
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
|
|
334
|
+
const raw = readConfigFile(filePath);
|
|
335
|
+
raw.servers[serverName] = config;
|
|
336
|
+
fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n");
|
|
337
|
+
return filePath;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Remove a server entry from the appropriate mcp.json.
|
|
342
|
+
*/
|
|
343
|
+
function removeServerFromConfig(
|
|
344
|
+
scope: "project" | "user",
|
|
345
|
+
serverName: string,
|
|
346
|
+
): boolean {
|
|
347
|
+
const projectDir = process.cwd();
|
|
348
|
+
const filePath = configFileForScope(scope, projectDir, USER_DIR);
|
|
349
|
+
if (!filePath || !fs.existsSync(filePath)) return false;
|
|
350
|
+
|
|
351
|
+
const raw = readConfigFile(filePath);
|
|
352
|
+
if (!raw.servers || !(serverName in raw.servers)) return false;
|
|
353
|
+
|
|
354
|
+
delete raw.servers[serverName];
|
|
355
|
+
fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n");
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Connect and register tools during factory (before tool snapshot) ───
|
|
360
|
+
|
|
361
|
+
const projectDir = process.cwd();
|
|
362
|
+
configResult = loadMergedConfig(projectDir, USER_DIR, EXTENSION_DIR);
|
|
363
|
+
|
|
364
|
+
for (const err of configResult.errors) {
|
|
365
|
+
connectionErrors.push({ name: err.server, message: err.message });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const entries = Object.entries(configResult.servers);
|
|
369
|
+
if (entries.length > 0) {
|
|
370
|
+
const results = await Promise.allSettled(
|
|
371
|
+
entries.map(([name, serverConfig]) => connectServer(name, serverConfig))
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < entries.length; i++) {
|
|
375
|
+
const [name] = entries[i];
|
|
376
|
+
const result = results[i];
|
|
377
|
+
|
|
378
|
+
if (result.status === "rejected") {
|
|
379
|
+
const reason = result.reason;
|
|
380
|
+
connectionErrors.push({
|
|
381
|
+
name,
|
|
382
|
+
message: isAuthError(reason)
|
|
383
|
+
? `authentication failed.\n${AUTH_REMEDIATION}`
|
|
384
|
+
: reason?.message ?? String(reason),
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const connected = result.value;
|
|
390
|
+
servers[name] = connected;
|
|
391
|
+
totalTools += registerToolsForServer(connected);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
398
|
+
// Report connection outcomes (connections already established in factory)
|
|
399
|
+
for (const err of connectionErrors) {
|
|
400
|
+
ctx.ui.notify(`[mcp-bridge] ${err.name}: ${err.message}`, "error");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (totalTools > 0) {
|
|
404
|
+
ctx.ui.notify(
|
|
405
|
+
`[mcp-bridge] ${totalTools} tools from ${Object.keys(servers).length} server(s)`,
|
|
406
|
+
"info"
|
|
407
|
+
);
|
|
408
|
+
} else if (connectionErrors.length === 0 && entries.length === 0) {
|
|
409
|
+
// No servers configured anywhere — that's fine, just silent
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("session_shutdown", async () => {
|
|
414
|
+
await Promise.allSettled(
|
|
415
|
+
Object.values(servers).map((s) => s.client.close())
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── Commands ───────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
pi.registerCommand("mcp", {
|
|
422
|
+
description: "Manage MCP servers: list, add, remove, test, reconnect",
|
|
423
|
+
getArgumentCompletions: (prefix: string) => {
|
|
424
|
+
const parts = prefix.split(/\s+/);
|
|
425
|
+
if (parts.length <= 1) {
|
|
426
|
+
const subs = ["list", "add", "remove", "test", "reconnect"];
|
|
427
|
+
const filtered = subs.filter(s => s.startsWith(parts[0] || ""));
|
|
428
|
+
return filtered.length > 0 ? filtered.map(s => ({ value: s, label: s })) : null;
|
|
429
|
+
}
|
|
430
|
+
const sub = parts[0];
|
|
431
|
+
if (sub === "remove" || sub === "test" || sub === "reconnect") {
|
|
432
|
+
const namePrefix = parts.slice(1).join(" ");
|
|
433
|
+
const allNames = [
|
|
434
|
+
...Object.keys(servers),
|
|
435
|
+
...Object.keys(configResult.servers).filter(k => !(k in servers)),
|
|
436
|
+
];
|
|
437
|
+
const filtered = allNames.filter(n => n.startsWith(namePrefix));
|
|
438
|
+
return filtered.length > 0
|
|
439
|
+
? filtered.map(n => ({ value: `${sub} ${n}`, label: n }))
|
|
440
|
+
: null;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
},
|
|
444
|
+
handler: async (args, ctx) => {
|
|
445
|
+
const parts = (args || "").trim().split(/\s+/);
|
|
446
|
+
const subcommand = parts[0] || "list";
|
|
447
|
+
const serverName = parts.slice(1).join(" ");
|
|
448
|
+
|
|
449
|
+
switch (subcommand) {
|
|
450
|
+
|
|
451
|
+
// ── /mcp list ──────────────────────────────────────────────────
|
|
452
|
+
case "list": {
|
|
453
|
+
// Reload config to show current state
|
|
454
|
+
const currentConfig = loadMergedConfig(process.cwd(), USER_DIR, EXTENSION_DIR);
|
|
455
|
+
const allServerNames = new Set([
|
|
456
|
+
...Object.keys(currentConfig.servers),
|
|
457
|
+
...Object.keys(servers),
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
if (allServerNames.size === 0) {
|
|
461
|
+
ctx.ui.notify(
|
|
462
|
+
"No MCP servers configured.\n\n" +
|
|
463
|
+
"Run /mcp add to connect a server, or create:\n" +
|
|
464
|
+
` ~/.pi/agent/mcp.json (user-level)\n` +
|
|
465
|
+
` .pi/mcp.json (project-level)`,
|
|
466
|
+
"info"
|
|
467
|
+
);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const lines: string[] = ["MCP Servers:", ""];
|
|
472
|
+
|
|
473
|
+
for (const name of allServerNames) {
|
|
474
|
+
const connected = servers[name];
|
|
475
|
+
const config = currentConfig.servers[name];
|
|
476
|
+
const source = currentConfig.sources[name] || "unknown";
|
|
477
|
+
|
|
478
|
+
const status = connected ? "✅ connected" : "❌ disconnected";
|
|
479
|
+
const toolCount = connected ? `${connected.tools.length} tools` : "—";
|
|
480
|
+
const transport = config
|
|
481
|
+
? isHttpConfig(config)
|
|
482
|
+
? config.url
|
|
483
|
+
: `stdio: ${(config as StdioServerConfig).command}`
|
|
484
|
+
: "config missing";
|
|
485
|
+
|
|
486
|
+
lines.push(` ${status} ${name} (${toolCount})`);
|
|
487
|
+
lines.push(` Transport: ${transport}`);
|
|
488
|
+
lines.push(` Source: ${source}`);
|
|
489
|
+
|
|
490
|
+
// Show secret status for referenced secrets
|
|
491
|
+
if (config) {
|
|
492
|
+
const refs = extractSecretRefs(config);
|
|
493
|
+
if (refs.length > 0) {
|
|
494
|
+
const secretStatus = refs.map(ref => {
|
|
495
|
+
const resolved = !!process.env[ref];
|
|
496
|
+
return `${resolved ? "✅" : "❌"} ${ref}`;
|
|
497
|
+
}).join(", ");
|
|
498
|
+
lines.push(` Secrets: ${secretStatus}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
lines.push("");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
lines.push("Commands: /mcp add | remove <name> | test <name> | reconnect <name>");
|
|
505
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── /mcp add ──────────────────────────────────────────────────
|
|
510
|
+
case "add": {
|
|
511
|
+
if (!ctx.hasUI) {
|
|
512
|
+
ctx.ui.notify("Cannot add servers without interactive UI", "error");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Step 1: Transport type
|
|
517
|
+
const transport = await ctx.ui.select(
|
|
518
|
+
"Add MCP Server\n\nSelect transport type:",
|
|
519
|
+
[
|
|
520
|
+
"HTTP — remote server (Streamable HTTP)",
|
|
521
|
+
"Stdio — local process (stdin/stdout)",
|
|
522
|
+
]
|
|
523
|
+
);
|
|
524
|
+
if (!transport) return;
|
|
525
|
+
|
|
526
|
+
if (transport.startsWith("HTTP")) {
|
|
527
|
+
// ── HTTP flow ──────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
// Step 2: URL
|
|
530
|
+
const url = await ctx.ui.input(
|
|
531
|
+
"Enter the MCP server URL:\n\n" +
|
|
532
|
+
"This is the Streamable HTTP endpoint, e.g.:\n" +
|
|
533
|
+
" https://scribe.recrocog.com/mcp/transport/"
|
|
534
|
+
);
|
|
535
|
+
if (!url) return;
|
|
536
|
+
|
|
537
|
+
// Validate URL
|
|
538
|
+
try {
|
|
539
|
+
new URL(url);
|
|
540
|
+
} catch {
|
|
541
|
+
ctx.ui.notify(`❌ Invalid URL: ${url}`, "error");
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Step 3: Server name
|
|
546
|
+
const suggestedName = slugifyUrl(url);
|
|
547
|
+
const name = await ctx.ui.input(
|
|
548
|
+
`Server name (used as prefix for tools, e.g. mcp_${suggestedName}_*):\n\n` +
|
|
549
|
+
`Leave blank for "${suggestedName}"`,
|
|
550
|
+
);
|
|
551
|
+
const finalName = (name || suggestedName).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
552
|
+
|
|
553
|
+
// Check for collision
|
|
554
|
+
if (configResult.servers[finalName]) {
|
|
555
|
+
const overwrite = await ctx.ui.confirm(
|
|
556
|
+
"Server exists",
|
|
557
|
+
`"${finalName}" is already configured (source: ${configResult.sources[finalName]}). Replace it?`
|
|
558
|
+
);
|
|
559
|
+
if (!overwrite) return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Step 4: Authentication
|
|
563
|
+
const authChoice = await ctx.ui.select(
|
|
564
|
+
`Authentication for ${finalName}:`,
|
|
565
|
+
[
|
|
566
|
+
"Bearer token — Authorization: Bearer $SECRET",
|
|
567
|
+
"API key header — custom header with secret",
|
|
568
|
+
"No authentication",
|
|
569
|
+
]
|
|
570
|
+
);
|
|
571
|
+
if (!authChoice) return;
|
|
572
|
+
|
|
573
|
+
let authType: "bearer" | "api-key" | "none";
|
|
574
|
+
let secretName: string | undefined;
|
|
575
|
+
let headerName: string | undefined;
|
|
576
|
+
|
|
577
|
+
if (authChoice.startsWith("Bearer")) {
|
|
578
|
+
authType = "bearer";
|
|
579
|
+
secretName = await ctx.ui.input(
|
|
580
|
+
"Secret name for the Bearer token:\n\n" +
|
|
581
|
+
"This is the environment variable / secret recipe name.\n" +
|
|
582
|
+
"Example: GITHUB_TOKEN, SCRIBE_API_KEY"
|
|
583
|
+
);
|
|
584
|
+
if (!secretName) return;
|
|
585
|
+
secretName = secretName.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
586
|
+
} else if (authChoice.startsWith("API key")) {
|
|
587
|
+
authType = "api-key";
|
|
588
|
+
headerName = await ctx.ui.input("Header name (e.g. X-Api-Key):");
|
|
589
|
+
if (!headerName) return;
|
|
590
|
+
secretName = await ctx.ui.input(
|
|
591
|
+
`Secret name for ${headerName}:\n\n` +
|
|
592
|
+
"Environment variable / secret recipe name."
|
|
593
|
+
);
|
|
594
|
+
if (!secretName) return;
|
|
595
|
+
secretName = secretName.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
|
596
|
+
} else {
|
|
597
|
+
authType = "none";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Step 5: Scope
|
|
601
|
+
const scope = await ctx.ui.select(
|
|
602
|
+
"Where should this server config be saved?",
|
|
603
|
+
[
|
|
604
|
+
"User-level (~/.pi/agent/mcp.json) — available in all projects",
|
|
605
|
+
"Project-level (.pi/mcp.json) — only this project",
|
|
606
|
+
]
|
|
607
|
+
);
|
|
608
|
+
if (!scope) return;
|
|
609
|
+
const configScope: "user" | "project" = scope.startsWith("User") ? "user" : "project";
|
|
610
|
+
|
|
611
|
+
// Build and write config
|
|
612
|
+
const serverConfig = buildHttpConfig(url, authType, secretName, headerName);
|
|
613
|
+
const writtenTo = writeServerToConfig(configScope, finalName, serverConfig);
|
|
614
|
+
|
|
615
|
+
if (!writtenTo) {
|
|
616
|
+
ctx.ui.notify("❌ Failed to write config", "error");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Check secret availability
|
|
621
|
+
let secretWarning = "";
|
|
622
|
+
if (secretName && !process.env[secretName]) {
|
|
623
|
+
secretWarning = `\n\n⚠️ Secret "${secretName}" is not configured.\n` +
|
|
624
|
+
`Run /secrets configure ${secretName} to set it up.`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Offer reload
|
|
628
|
+
ctx.ui.notify(
|
|
629
|
+
`✅ Server "${finalName}" added to ${configScope} config.\n` +
|
|
630
|
+
` Written to: ${writtenTo}${secretWarning}`,
|
|
631
|
+
"info"
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
const reload = await ctx.ui.confirm(
|
|
635
|
+
"Reload required",
|
|
636
|
+
"Reload pi to connect and register tools from the new server?"
|
|
637
|
+
);
|
|
638
|
+
if (reload) {
|
|
639
|
+
await ctx.reload();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
} else {
|
|
643
|
+
// ── Stdio flow ─────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
// Step 2: Command
|
|
646
|
+
const commandInput = await ctx.ui.input(
|
|
647
|
+
"Enter the command to start the MCP server:\n\n" +
|
|
648
|
+
"Examples:\n" +
|
|
649
|
+
" npx -y @example/mcp-server\n" +
|
|
650
|
+
" python -m my_mcp_server\n" +
|
|
651
|
+
" /usr/local/bin/my-tool serve"
|
|
652
|
+
);
|
|
653
|
+
if (!commandInput) return;
|
|
654
|
+
|
|
655
|
+
const { command, args: cmdArgs } = parseCommand(commandInput);
|
|
656
|
+
if (!command) {
|
|
657
|
+
ctx.ui.notify("❌ No command provided", "error");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Step 3: Server name
|
|
662
|
+
const suggestedName = path.basename(command).replace(/\.[^.]+$/, "");
|
|
663
|
+
const name = await ctx.ui.input(
|
|
664
|
+
`Server name (used as prefix for tools, e.g. mcp_${suggestedName}_*):\n\n` +
|
|
665
|
+
`Leave blank for "${suggestedName}"`,
|
|
666
|
+
);
|
|
667
|
+
const finalName = (name || suggestedName).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
668
|
+
|
|
669
|
+
// Check for collision
|
|
670
|
+
if (configResult.servers[finalName]) {
|
|
671
|
+
const overwrite = await ctx.ui.confirm(
|
|
672
|
+
"Server exists",
|
|
673
|
+
`"${finalName}" is already configured (source: ${configResult.sources[finalName]}). Replace it?`
|
|
674
|
+
);
|
|
675
|
+
if (!overwrite) return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Step 4: Environment variables (optional)
|
|
679
|
+
let env: Record<string, string> | undefined;
|
|
680
|
+
const addEnv = await ctx.ui.confirm(
|
|
681
|
+
"Environment variables",
|
|
682
|
+
"Does this server need environment variables (e.g. API keys)?"
|
|
683
|
+
);
|
|
684
|
+
if (addEnv) {
|
|
685
|
+
env = {};
|
|
686
|
+
let more = true;
|
|
687
|
+
while (more) {
|
|
688
|
+
const pair = await ctx.ui.input(
|
|
689
|
+
"Enter environment variable (KEY=value or KEY=${SECRET_NAME}):\n\n" +
|
|
690
|
+
"Use ${SECRET_NAME} to reference a managed secret."
|
|
691
|
+
);
|
|
692
|
+
if (pair) {
|
|
693
|
+
const eqIdx = pair.indexOf("=");
|
|
694
|
+
if (eqIdx > 0) {
|
|
695
|
+
env[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
more = await ctx.ui.confirm("More?", "Add another environment variable?");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Step 5: Scope
|
|
703
|
+
const scope = await ctx.ui.select(
|
|
704
|
+
"Where should this server config be saved?",
|
|
705
|
+
[
|
|
706
|
+
"User-level (~/.pi/agent/mcp.json) — available in all projects",
|
|
707
|
+
"Project-level (.pi/mcp.json) — only this project",
|
|
708
|
+
]
|
|
709
|
+
);
|
|
710
|
+
if (!scope) return;
|
|
711
|
+
const configScope: "user" | "project" = scope.startsWith("User") ? "user" : "project";
|
|
712
|
+
|
|
713
|
+
// Build and write config
|
|
714
|
+
const serverConfig = buildStdioConfig(command, cmdArgs, env);
|
|
715
|
+
const writtenTo = writeServerToConfig(configScope, finalName, serverConfig);
|
|
716
|
+
|
|
717
|
+
if (!writtenTo) {
|
|
718
|
+
ctx.ui.notify("❌ Failed to write config", "error");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Check secret availability for env refs
|
|
723
|
+
const refs = extractSecretRefs(serverConfig);
|
|
724
|
+
let secretWarning = "";
|
|
725
|
+
const missing = refs.filter(r => !process.env[r]);
|
|
726
|
+
if (missing.length > 0) {
|
|
727
|
+
secretWarning = `\n\n⚠️ Missing secrets: ${missing.join(", ")}\n` +
|
|
728
|
+
missing.map(s => `Run /secrets configure ${s}`).join("\n");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
ctx.ui.notify(
|
|
732
|
+
`✅ Server "${finalName}" added to ${configScope} config.\n` +
|
|
733
|
+
` Written to: ${writtenTo}${secretWarning}`,
|
|
734
|
+
"info"
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
const reload = await ctx.ui.confirm(
|
|
738
|
+
"Reload required",
|
|
739
|
+
"Reload pi to connect and register tools from the new server?"
|
|
740
|
+
);
|
|
741
|
+
if (reload) {
|
|
742
|
+
await ctx.reload();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── /mcp remove <name> ────────────────────────────────────────
|
|
749
|
+
case "remove":
|
|
750
|
+
case "rm": {
|
|
751
|
+
if (!serverName) {
|
|
752
|
+
// Interactive: let user pick
|
|
753
|
+
if (!ctx.hasUI) {
|
|
754
|
+
ctx.ui.notify("Usage: /mcp remove <server-name>", "error");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const currentConfig = loadMergedConfig(process.cwd(), USER_DIR, EXTENSION_DIR);
|
|
758
|
+
const removable = Object.entries(currentConfig.sources)
|
|
759
|
+
.filter(([_, source]) => source !== "bundled")
|
|
760
|
+
.map(([name, source]) => `${name} (${source})`);
|
|
761
|
+
|
|
762
|
+
if (removable.length === 0) {
|
|
763
|
+
ctx.ui.notify("No removable servers. Bundled servers can only be overridden, not removed.", "info");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const choice = await ctx.ui.select("Remove which server?", removable);
|
|
768
|
+
if (!choice) return;
|
|
769
|
+
|
|
770
|
+
const chosenName = choice.split(/\s+/)[0];
|
|
771
|
+
const chosenSource = currentConfig.sources[chosenName] as "project" | "user";
|
|
772
|
+
|
|
773
|
+
const confirm = await ctx.ui.confirm(
|
|
774
|
+
"Confirm removal",
|
|
775
|
+
`Remove "${chosenName}" from ${chosenSource} config?`
|
|
776
|
+
);
|
|
777
|
+
if (!confirm) return;
|
|
778
|
+
|
|
779
|
+
if (removeServerFromConfig(chosenSource, chosenName)) {
|
|
780
|
+
ctx.ui.notify(`✅ Removed "${chosenName}" from ${chosenSource} config.`, "info");
|
|
781
|
+
const reload = await ctx.ui.confirm("Reload?", "Reload pi to apply changes?");
|
|
782
|
+
if (reload) await ctx.reload();
|
|
783
|
+
} else {
|
|
784
|
+
ctx.ui.notify(`❌ Failed to remove "${chosenName}"`, "error");
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Named removal
|
|
790
|
+
const currentConfig = loadMergedConfig(process.cwd(), USER_DIR, EXTENSION_DIR);
|
|
791
|
+
const source = currentConfig.sources[serverName];
|
|
792
|
+
if (!source) {
|
|
793
|
+
ctx.ui.notify(`❌ No server named "${serverName}" found`, "error");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (source === "bundled") {
|
|
797
|
+
ctx.ui.notify(
|
|
798
|
+
`❌ "${serverName}" is a bundled server. You can override it with /mcp add, but not remove it.`,
|
|
799
|
+
"error"
|
|
800
|
+
);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (ctx.hasUI) {
|
|
805
|
+
const confirm = await ctx.ui.confirm(
|
|
806
|
+
"Confirm removal",
|
|
807
|
+
`Remove "${serverName}" from ${source} config?`
|
|
808
|
+
);
|
|
809
|
+
if (!confirm) return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (removeServerFromConfig(source as "project" | "user", serverName)) {
|
|
813
|
+
ctx.ui.notify(`✅ Removed "${serverName}" from ${source} config.`, "info");
|
|
814
|
+
if (ctx.hasUI) {
|
|
815
|
+
const reload = await ctx.ui.confirm("Reload?", "Reload pi to apply changes?");
|
|
816
|
+
if (reload) await ctx.reload();
|
|
817
|
+
}
|
|
818
|
+
} else {
|
|
819
|
+
ctx.ui.notify(`❌ Failed to remove "${serverName}"`, "error");
|
|
820
|
+
}
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── /mcp test <name> ──────────────────────────────────────────
|
|
825
|
+
case "test": {
|
|
826
|
+
if (!serverName) {
|
|
827
|
+
// Interactive: let user pick
|
|
828
|
+
if (!ctx.hasUI) {
|
|
829
|
+
ctx.ui.notify("Usage: /mcp test <server-name>", "error");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const currentConfig = loadMergedConfig(process.cwd(), USER_DIR, EXTENSION_DIR);
|
|
833
|
+
const names = Object.keys(currentConfig.servers);
|
|
834
|
+
if (names.length === 0) {
|
|
835
|
+
ctx.ui.notify("No servers configured to test.", "info");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const choice = await ctx.ui.select("Test which server?", names);
|
|
839
|
+
if (!choice) return;
|
|
840
|
+
// Recurse with the chosen name
|
|
841
|
+
await handleTest(choice, ctx);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
await handleTest(serverName, ctx);
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── /mcp reconnect <name> ────────────────────────────────────
|
|
849
|
+
case "reconnect": {
|
|
850
|
+
if (!serverName) {
|
|
851
|
+
if (!ctx.hasUI) {
|
|
852
|
+
ctx.ui.notify("Usage: /mcp reconnect <server-name>", "error");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const connectedNames = Object.keys(servers);
|
|
856
|
+
const allNames = Object.keys(configResult.servers);
|
|
857
|
+
const names = [...new Set([...connectedNames, ...allNames])];
|
|
858
|
+
if (names.length === 0) {
|
|
859
|
+
ctx.ui.notify("No servers to reconnect.", "info");
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const choice = await ctx.ui.select("Reconnect which server?", names);
|
|
863
|
+
if (!choice) return;
|
|
864
|
+
await handleReconnect(choice, ctx);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
await handleReconnect(serverName, ctx);
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── /mcp (no args or unknown) ────────────────────────────────
|
|
872
|
+
default: {
|
|
873
|
+
ctx.ui.notify(
|
|
874
|
+
"Usage: /mcp <list|add|remove|test|reconnect> [name]\n\n" +
|
|
875
|
+
" /mcp list — show all configured servers and status\n" +
|
|
876
|
+
" /mcp add — guided setup for a new server\n" +
|
|
877
|
+
" /mcp remove [name] — remove a server from config\n" +
|
|
878
|
+
" /mcp test [name] — test connection to a server\n" +
|
|
879
|
+
" /mcp reconnect [name] — reconnect a server",
|
|
880
|
+
"info"
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// ── Command helpers (avoid deep nesting) ────────────────────────────────
|
|
888
|
+
|
|
889
|
+
async function handleTest(name: string, ctx: any) {
|
|
890
|
+
const currentConfig = loadMergedConfig(process.cwd(), USER_DIR, EXTENSION_DIR);
|
|
891
|
+
const config = currentConfig.servers[name];
|
|
892
|
+
if (!config) {
|
|
893
|
+
ctx.ui.notify(`❌ No server named "${name}" in config`, "error");
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Check secret availability first
|
|
898
|
+
const refs = extractSecretRefs(config);
|
|
899
|
+
const missing = refs.filter(r => !process.env[r]);
|
|
900
|
+
if (missing.length > 0) {
|
|
901
|
+
ctx.ui.notify(
|
|
902
|
+
`❌ Cannot test "${name}" — missing secrets: ${missing.join(", ")}\n` +
|
|
903
|
+
missing.map((s: string) => ` Run /secrets configure ${s}`).join("\n"),
|
|
904
|
+
"error"
|
|
905
|
+
);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
ctx.ui.notify(`Testing connection to "${name}"...`, "info");
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
const connected = await connectServer(name, config);
|
|
913
|
+
const toolCount = connected.tools.length;
|
|
914
|
+
const toolNames = connected.tools.slice(0, 5).map((t: any) => t.name).join(", ");
|
|
915
|
+
const suffix = toolCount > 5 ? `, ... (+${toolCount - 5} more)` : "";
|
|
916
|
+
|
|
917
|
+
// Clean up test connection
|
|
918
|
+
try { await connected.client.close(); } catch {}
|
|
919
|
+
|
|
920
|
+
ctx.ui.notify(
|
|
921
|
+
`✅ "${name}" connected successfully!\n` +
|
|
922
|
+
` ${toolCount} tools: ${toolNames}${suffix}`,
|
|
923
|
+
"info"
|
|
924
|
+
);
|
|
925
|
+
} catch (err: any) {
|
|
926
|
+
const msg = isAuthError(err)
|
|
927
|
+
? `Authentication failed.\n${AUTH_REMEDIATION}`
|
|
928
|
+
: err.message;
|
|
929
|
+
ctx.ui.notify(`❌ "${name}" connection failed:\n ${msg}`, "error");
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function handleReconnect(name: string, ctx: any) {
|
|
934
|
+
const config = configResult.servers[name];
|
|
935
|
+
if (!config) {
|
|
936
|
+
ctx.ui.notify(`❌ No server named "${name}" in config`, "error");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
ctx.ui.notify(`Reconnecting "${name}"...`, "info");
|
|
941
|
+
const result = await reconnectServer(name, config);
|
|
942
|
+
if (result) {
|
|
943
|
+
ctx.ui.notify(
|
|
944
|
+
`✅ "${name}" reconnected (${result.tools.length} tools)`,
|
|
945
|
+
"info"
|
|
946
|
+
);
|
|
947
|
+
} else {
|
|
948
|
+
ctx.ui.notify(`❌ "${name}" reconnection failed`, "error");
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|