@yagni-app/code 1.0.4 → 1.0.6

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.
Files changed (51) hide show
  1. package/README.md +30 -6
  2. package/dist/claudePlugins.d.ts +3 -1
  3. package/dist/claudePlugins.js +3 -1
  4. package/dist/cli.js +12 -0
  5. package/dist/doctor.d.ts +28 -3
  6. package/dist/doctor.js +117 -7
  7. package/dist/extension/index.d.ts +5 -5
  8. package/dist/extension/index.js +94 -31
  9. package/dist/extension/mcp/approval.d.ts +45 -0
  10. package/dist/extension/mcp/approval.js +164 -0
  11. package/dist/extension/mcp/auth.d.ts +124 -0
  12. package/dist/extension/mcp/auth.js +560 -0
  13. package/dist/extension/mcp/authStore.d.ts +61 -0
  14. package/dist/extension/mcp/authStore.js +105 -0
  15. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  16. package/dist/extension/mcp/callbackPage.js +222 -0
  17. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  18. package/dist/extension/mcp/cliConfig.js +12 -0
  19. package/dist/extension/mcp/config.d.ts +131 -0
  20. package/dist/extension/mcp/config.js +309 -0
  21. package/dist/extension/mcp/log.d.ts +28 -0
  22. package/dist/extension/mcp/log.js +82 -0
  23. package/dist/extension/mcp/manager.d.ts +98 -0
  24. package/dist/extension/mcp/manager.js +273 -0
  25. package/dist/extension/mcp/names.d.ts +25 -0
  26. package/dist/extension/mcp/names.js +40 -0
  27. package/dist/extension/mcp/panel.d.ts +34 -0
  28. package/dist/extension/mcp/panel.js +258 -0
  29. package/dist/extension/mcp/prompts.d.ts +23 -0
  30. package/dist/extension/mcp/prompts.js +93 -0
  31. package/dist/extension/mcp/startup.d.ts +55 -0
  32. package/dist/extension/mcp/startup.js +150 -0
  33. package/dist/extension/mcp/tools.d.ts +31 -0
  34. package/dist/extension/mcp/tools.js +117 -0
  35. package/dist/extension/mcp/transports.d.ts +17 -0
  36. package/dist/extension/mcp/transports.js +44 -0
  37. package/dist/extension/permission/execPolicy.js +17 -2
  38. package/dist/extension/permission/gate.d.ts +7 -0
  39. package/dist/extension/permission/gate.js +12 -5
  40. package/dist/extension/permission/guardian.d.ts +24 -5
  41. package/dist/extension/permission/guardian.js +162 -24
  42. package/dist/extension/pipeline/personas.js +5 -0
  43. package/dist/mcpCommand.d.ts +113 -0
  44. package/dist/mcpCommand.js +755 -0
  45. package/dist/otel.d.ts +36 -7
  46. package/dist/otel.js +90 -12
  47. package/dist/upgrade.d.ts +11 -2
  48. package/dist/upgrade.js +48 -8
  49. package/package.json +3 -2
  50. package/dist/extension/mcpTools.d.ts +0 -57
  51. package/dist/extension/mcpTools.js +0 -132
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The MCP manager: owns one live client per configured server, tracks
3
+ * connection state, and exposes connect/reconnect/disconnect operations used
4
+ * by both the /mcp panel and session startup. Connection state is what the
5
+ * panel renders; tool/prompt registration is layered on top by tools.ts /
6
+ * prompts.ts once a server connects.
7
+ *
8
+ * Everything here is fail-soft: a server that fails to connect lands in
9
+ * `failed` with its error message, never blocking the session or the other
10
+ * servers. `MCP_TIMEOUT` (connect, ms) and `MCP_TOOL_TIMEOUT` (call, ms) are
11
+ * honored per Claude Code's env parity.
12
+ */
13
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
15
+ import { expandServerEnv } from "./config.js";
16
+ import { DEFAULT_CONNECT_TIMEOUT_MS, transportFor } from "./transports.js";
17
+ import { authenticate, authProviderForServer, instrumentOAuthFetch, AuthenticationCancelledError } from "./auth.js";
18
+ export function connectTimeoutFromEnv(env, fallback = DEFAULT_CONNECT_TIMEOUT_MS) {
19
+ const raw = env.MCP_TIMEOUT;
20
+ if (!raw)
21
+ return fallback;
22
+ const n = Number(raw);
23
+ return Number.isFinite(n) && n > 0 ? n : fallback;
24
+ }
25
+ export function toolTimeoutFromEnv(env) {
26
+ const raw = env.MCP_TOOL_TIMEOUT;
27
+ if (!raw)
28
+ return undefined;
29
+ const n = Number(raw);
30
+ return Number.isFinite(n) && n > 0 ? n : undefined;
31
+ }
32
+ export class McpManager {
33
+ servers = new Map();
34
+ events;
35
+ connectTimeoutMs;
36
+ env;
37
+ closed = false;
38
+ constructor(opts = {}) {
39
+ this.events = opts.events ?? {};
40
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
41
+ this.env = opts.env ?? process.env;
42
+ }
43
+ list() {
44
+ return [...this.servers.values()];
45
+ }
46
+ get(name) {
47
+ return this.servers.get(name);
48
+ }
49
+ /** Registers a server without connecting (panel listing, kill-switch states). */
50
+ register(name, scope, config, status = "disabled") {
51
+ const existing = this.servers.get(name);
52
+ if (existing)
53
+ return existing;
54
+ const server = { name, scope, config, status };
55
+ this.servers.set(name, server);
56
+ return server;
57
+ }
58
+ async connect(name) {
59
+ if (this.closed)
60
+ return undefined;
61
+ const server = this.servers.get(name);
62
+ if (!server)
63
+ return undefined;
64
+ if (server.status === "connected")
65
+ return server;
66
+ this.setStatus(server, "connecting");
67
+ // Expand ${VAR} at connect time only: the stored config (panel display,
68
+ // OAuth auth-store keys) stays raw; the wire gets the resolved values.
69
+ // A missing var fails fast with a clear error instead of sending a
70
+ // literal "${VAR}" header (which the SDK retries until timeout).
71
+ const expanded = expandServerEnv(server.config, this.env);
72
+ if (expanded.missingVars.length > 0) {
73
+ server.error = `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and reconnect via /mcp`;
74
+ this.setStatus(server, "failed");
75
+ return server;
76
+ }
77
+ const oauth = isOAuthServer(server.config);
78
+ const authProvider = oauth ? authProviderForServer(name, server.config) : undefined;
79
+ const fetchImpl = oauth ? instrumentOAuthFetch(name, fetch) : undefined;
80
+ const client = new Client({ name: "yagni-code", version: "1.0" });
81
+ let transport;
82
+ try {
83
+ // Inside the try: an expanded URL that is not a valid URL throws from
84
+ // `new URL()` in transportFor and must land in `failed`, not escape the
85
+ // manager. Errors are sanitized before display — an expanded value that
86
+ // an SDK error echoes back must never reach the panel verbatim.
87
+ transport = transportFor(expanded.config, authProvider, fetchImpl);
88
+ await withTimeout(client.connect(transport), this.connectTimeoutMs, `connect timed out after ${this.connectTimeoutMs}ms`);
89
+ }
90
+ catch (err) {
91
+ await safeClose(client);
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ server.error = sanitizeError(message);
94
+ this.setStatus(server, err instanceof UnauthorizedError ? "needs_auth" : classifyFailure(message));
95
+ return server;
96
+ }
97
+ server.client = client;
98
+ server.transport = transport;
99
+ server.connectedAt = Date.now();
100
+ server.error = undefined;
101
+ this.setStatus(server, "connected");
102
+ return server;
103
+ }
104
+ async disconnect(name) {
105
+ const server = this.servers.get(name);
106
+ if (!server)
107
+ return;
108
+ if (server.client)
109
+ await safeClose(server.client);
110
+ server.client = undefined;
111
+ server.transport = undefined;
112
+ if (server.status !== "disabled")
113
+ this.setStatus(server, "disabled");
114
+ }
115
+ /** Reconnect with bounded attempts, panel-visible progress. */
116
+ async reconnect(name, maxAttempts = 3) {
117
+ const server = this.servers.get(name);
118
+ if (!server)
119
+ return undefined;
120
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
121
+ server.reconnectAttempt = attempt;
122
+ this.setStatus(server, "reconnecting");
123
+ const result = await this.connect(name);
124
+ if (result && result.status === "connected") {
125
+ server.reconnectAttempt = undefined;
126
+ return result;
127
+ }
128
+ await sleep(Math.min(500 * 2 ** (attempt - 1), 2000));
129
+ }
130
+ server.reconnectAttempt = undefined;
131
+ return server;
132
+ }
133
+ /**
134
+ * Drive the interactive OAuth flow for a `needs_auth` server, then reconnect.
135
+ * The flow opens a browser and returns once tokens are persisted; a cancelled
136
+ * or failed flow leaves the server in `needs_auth` and returns its error.
137
+ * `signal` cancels the loopback wait (Esc / session abort) instead of holding
138
+ * the command handler for the full 5-minute timeout. `authDeps` is the same
139
+ * seam `authenticate` takes (openUrl/fetch) so tests drive the real path
140
+ * without a browser or network.
141
+ */
142
+ async authenticateServer(name, signal, authDeps = {}) {
143
+ const server = this.servers.get(name);
144
+ if (!server)
145
+ return undefined;
146
+ if (!isOAuthServer(server.config)) {
147
+ server.error = "this server does not use OAuth (stdio servers have no auth flow)";
148
+ this.setStatus(server, "failed");
149
+ return server;
150
+ }
151
+ // OAuth discovery + token exchange must hit the ${VAR}-expanded URL (the
152
+ // literal reference is not a resolvable endpoint); the raw config is still
153
+ // what authenticate() keys the auth store with. Missing vars fail here the
154
+ // same way connect() does — names only.
155
+ const expanded = expandServerEnv(server.config, this.env);
156
+ if (expanded.missingVars.length > 0) {
157
+ server.error = `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and reconnect via /mcp`;
158
+ this.setStatus(server, "failed");
159
+ return server;
160
+ }
161
+ try {
162
+ await authenticate(name, server.config, authDeps, {
163
+ signal,
164
+ serverUrl: expanded.config.url,
165
+ });
166
+ }
167
+ catch (err) {
168
+ if (err instanceof AuthenticationCancelledError) {
169
+ server.error = undefined;
170
+ }
171
+ else {
172
+ server.error = err instanceof Error ? err.message : String(err);
173
+ }
174
+ this.setStatus(server, "needs_auth");
175
+ return server;
176
+ }
177
+ return this.connect(name);
178
+ }
179
+ /** Closes every connected client. Called on session_shutdown (any reason). */
180
+ async closeAll() {
181
+ this.closed = true;
182
+ await Promise.all([...this.servers.keys()].map((name) => this.disconnect(name)));
183
+ }
184
+ setStatus(server, status) {
185
+ server.status = status;
186
+ this.events.onStateChange?.(server);
187
+ this.events.onLog?.(JSON.stringify({
188
+ ts: new Date().toISOString(),
189
+ server: server.name,
190
+ scope: server.scope,
191
+ status,
192
+ ...(server.error ? { error: sanitizeError(server.error) } : {}),
193
+ }));
194
+ }
195
+ }
196
+ /**
197
+ * One-off health check for a configured server, used by `yagni mcp list` /
198
+ * `get` and `yagni doctor`. Does NOT mutate manager state: it builds a fresh
199
+ * transport + client, connect()s against it, classifies the result, and closes
200
+ * the client. OAuth-capable (http/sse) servers get an auth provider so a fresh
201
+ * token (or the redirect-required `needs_auth`) is reported faithfully.
202
+ *
203
+ * Callers apply the approval gate themselves: a project-scope server that is
204
+ * undecided/disabled must be reported as such WITHOUT connecting (fail-closed,
205
+ * never spawn a process the user has not approved), so `probeServer` never
206
+ * asks about approval — it only ever connects what it is given.
207
+ */
208
+ export async function probeServer(name, config, opts = {}) {
209
+ const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
210
+ const expanded = expandServerEnv(config, opts.env ?? process.env);
211
+ if (expanded.missingVars.length > 0) {
212
+ return {
213
+ status: "failed",
214
+ error: `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and try again`,
215
+ };
216
+ }
217
+ const oauth = isOAuthServer(config);
218
+ const authProvider = oauth ? authProviderForServer(name, config) : undefined;
219
+ const fetchImpl = oauth ? instrumentOAuthFetch(name, fetch) : undefined;
220
+ const client = new Client({ name: "yagni-code", version: "1.0" });
221
+ try {
222
+ // transportFor inside the try: `new URL()` on a bad expanded URL must
223
+ // report `failed`, not throw out of the probe.
224
+ const transport = transportFor(expanded.config, authProvider, fetchImpl);
225
+ await withTimeout(client.connect(transport), timeoutMs, `connect timed out after ${timeoutMs}ms`);
226
+ return { status: "connected" };
227
+ }
228
+ catch (err) {
229
+ const message = err instanceof Error ? err.message : String(err);
230
+ const status = err instanceof UnauthorizedError ? "needs_auth" : classifyFailure(message);
231
+ return { status, error: sanitizeError(message) };
232
+ }
233
+ finally {
234
+ await safeClose(client);
235
+ }
236
+ }
237
+ /** 401/403 → needs_auth; everything else → failed. */
238
+ export function classifyFailure(message) {
239
+ if (/\b(401|403)\b/.test(message) || /unauthorized|forbidden/i.test(message))
240
+ return "needs_auth";
241
+ return "failed";
242
+ }
243
+ export function sanitizeError(message) {
244
+ return message.replace(/(bearer|token|key|secret|password)[^\s]*/gi, "$1=REDACTED").slice(0, 500);
245
+ }
246
+ /** HTTP/SSE servers support OAuth; stdio servers do not. */
247
+ function isOAuthServer(config) {
248
+ return config.type === "http" || config.type === "sse";
249
+ }
250
+ async function safeClose(client) {
251
+ try {
252
+ await client.close();
253
+ }
254
+ catch {
255
+ // best-effort close
256
+ }
257
+ }
258
+ function sleep(ms) {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }
261
+ function withTimeout(promise, ms, message) {
262
+ return new Promise((resolve, reject) => {
263
+ const timer = setTimeout(() => reject(new Error(message)), ms);
264
+ promise.then((value) => {
265
+ clearTimeout(timer);
266
+ resolve(value);
267
+ }, (err) => {
268
+ clearTimeout(timer);
269
+ reject(err);
270
+ });
271
+ });
272
+ }
273
+ //# sourceMappingURL=manager.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * MCP server/tool name normalization + qualified-name building, ported from
3
+ * Claude Code's `services/mcp/{normalization,mcpStringUtils}.ts` so tool names
4
+ * (`mcp__<server>__<tool>`) are byte-identical across the two agents — a
5
+ * permission rule written for one works for the other.
6
+ *
7
+ * Known limitation (inherited from Claude Code, documented there too): if a
8
+ * server name contains `__`, `mcpInfoFromString` splits on the first segment.
9
+ */
10
+ /** Replace characters an MCP tool name cannot carry with `_` (Claude Code parity). */
11
+ export declare function normalizeNameForMCP(name: string): string;
12
+ /** Whether a raw server/tool name is usable as-is (matches Claude Code's validity pattern). */
13
+ export declare function isValidMcpName(name: string): boolean;
14
+ /** The `mcp__<server>__` prefix for a server name. */
15
+ export declare function getMcpPrefix(serverName: string): string;
16
+ /** Fully qualified tool name, e.g. `mcp__linear__get_issue`. */
17
+ export declare function buildMcpToolName(serverName: string, toolName: string): string;
18
+ /** Fully qualified prompt/slash-command name, e.g. `mcp__linear__issue_prompt`. */
19
+ export declare function buildMcpPromptName(serverName: string, promptName: string): string;
20
+ /** Parse `mcp__<server>__<tool>` back into its parts; null when not an MCP name. */
21
+ export declare function mcpInfoFromString(toolString: string): {
22
+ serverName: string;
23
+ toolName: string | undefined;
24
+ } | null;
25
+ //# sourceMappingURL=names.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * MCP server/tool name normalization + qualified-name building, ported from
3
+ * Claude Code's `services/mcp/{normalization,mcpStringUtils}.ts` so tool names
4
+ * (`mcp__<server>__<tool>`) are byte-identical across the two agents — a
5
+ * permission rule written for one works for the other.
6
+ *
7
+ * Known limitation (inherited from Claude Code, documented there too): if a
8
+ * server name contains `__`, `mcpInfoFromString` splits on the first segment.
9
+ */
10
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
11
+ /** Replace characters an MCP tool name cannot carry with `_` (Claude Code parity). */
12
+ export function normalizeNameForMCP(name) {
13
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_");
14
+ }
15
+ /** Whether a raw server/tool name is usable as-is (matches Claude Code's validity pattern). */
16
+ export function isValidMcpName(name) {
17
+ return NAME_PATTERN.test(name);
18
+ }
19
+ /** The `mcp__<server>__` prefix for a server name. */
20
+ export function getMcpPrefix(serverName) {
21
+ return `mcp__${normalizeNameForMCP(serverName)}__`;
22
+ }
23
+ /** Fully qualified tool name, e.g. `mcp__linear__get_issue`. */
24
+ export function buildMcpToolName(serverName, toolName) {
25
+ return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`;
26
+ }
27
+ /** Fully qualified prompt/slash-command name, e.g. `mcp__linear__issue_prompt`. */
28
+ export function buildMcpPromptName(serverName, promptName) {
29
+ return buildMcpToolName(serverName, promptName);
30
+ }
31
+ /** Parse `mcp__<server>__<tool>` back into its parts; null when not an MCP name. */
32
+ export function mcpInfoFromString(toolString) {
33
+ const parts = toolString.split("__");
34
+ const [mcpPart, serverName, ...toolNameParts] = parts;
35
+ if (mcpPart !== "mcp" || !serverName)
36
+ return null;
37
+ const toolName = toolNameParts.length > 0 ? toolNameParts.join("__") : undefined;
38
+ return { serverName, toolName };
39
+ }
40
+ //# sourceMappingURL=names.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The /mcp interactive panel (Claude Code parity): a two-level select —
3
+ * server list grouped by scope, then a per-server detail menu — plus a
4
+ * plain-text listing fallback for print/headless mode and for `args` given
5
+ * directly to /mcp (e.g. `/mcp tools <name>`).
6
+ *
7
+ * Enable/disable persists through ~/.yagni-code/mcp.json using Claude Code's
8
+ * own key names (enabledMcpjsonServers / disabledMcpjsonServers for project
9
+ * servers; the local-scope `disabled` convention for user/local servers via
10
+ * a `disabledMcpServers` list under projects[absPath]). A disable/enable
11
+ * toggle reconnects or disconnects the live client and refreshes tools.
12
+ */
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import type { ManagedServer, McpManager } from "./manager.js";
15
+ export interface PanelServerInfo {
16
+ name: string;
17
+ scope: string;
18
+ status: string;
19
+ statusIcon: string;
20
+ detail: string;
21
+ }
22
+ export declare function statusIcon(status: string): string;
23
+ export declare function statusText(server: ManagedServer): string;
24
+ /** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
25
+ export declare function renderPanelListing(servers: ManagedServer[], errors: string[]): string;
26
+ export interface PanelDeps {
27
+ cwd: string;
28
+ hasUI: boolean;
29
+ }
30
+ /** Wire the /mcp command + subcommands. */
31
+ export declare function registerMcpPanel(pi: ExtensionAPI, manager: McpManager, configErrors: () => string[]): void;
32
+ /** Read the persisted disabled list for a repo root (exported for tests). */
33
+ export declare function readDisabledServers(repoRoot: string): string[];
34
+ //# sourceMappingURL=panel.d.ts.map
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The /mcp interactive panel (Claude Code parity): a two-level select —
3
+ * server list grouped by scope, then a per-server detail menu — plus a
4
+ * plain-text listing fallback for print/headless mode and for `args` given
5
+ * directly to /mcp (e.g. `/mcp tools <name>`).
6
+ *
7
+ * Enable/disable persists through ~/.yagni-code/mcp.json using Claude Code's
8
+ * own key names (enabledMcpjsonServers / disabledMcpjsonServers for project
9
+ * servers; the local-scope `disabled` convention for user/local servers via
10
+ * a `disabledMcpServers` list under projects[absPath]). A disable/enable
11
+ * toggle reconnects or disconnects the live client and refreshes tools.
12
+ */
13
+ import { readUserMcpConfig, resolveProjectRoot, writeUserMcpConfig } from "./config.js";
14
+ import { registerServerPrompts } from "./prompts.js";
15
+ import { registerServerTools } from "./tools.js";
16
+ const STATUS_ICONS = {
17
+ connected: "✓",
18
+ connecting: "…",
19
+ reconnecting: "…",
20
+ failed: "✗",
21
+ needs_auth: "⚠",
22
+ disabled: "○",
23
+ };
24
+ const STATUS_TEXT = {
25
+ connected: "connected",
26
+ connecting: "connecting…",
27
+ reconnecting: "reconnecting",
28
+ failed: "failed to connect",
29
+ needs_auth: "needs authentication",
30
+ disabled: "disabled",
31
+ };
32
+ export function statusIcon(status) {
33
+ return STATUS_ICONS[status] ?? "○";
34
+ }
35
+ export function statusText(server) {
36
+ if (server.status === "reconnecting" && server.reconnectAttempt) {
37
+ return `reconnecting (${server.reconnectAttempt}/3)…`;
38
+ }
39
+ return STATUS_TEXT[server.status] ?? server.status;
40
+ }
41
+ /** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
42
+ export function renderPanelListing(servers, errors) {
43
+ if (servers.length === 0 && errors.length === 0) {
44
+ return [
45
+ "No MCP servers configured.",
46
+ "Use `yagni mcp add` to add a server, or `/doctor` if this is unexpected.",
47
+ ].join("\n");
48
+ }
49
+ const lines = [];
50
+ const byScope = { user: [], project: [], local: [] };
51
+ for (const s of servers)
52
+ (byScope[s.scope] ??= []).push(s);
53
+ for (const scope of ["local", "project", "user"]) {
54
+ const group = byScope[scope];
55
+ if (!group?.length)
56
+ continue;
57
+ lines.push(`${scope === "local" ? "Local" : scope === "project" ? "Project" : "User"} servers:`);
58
+ for (const s of group) {
59
+ lines.push(` ${s.name} · ${statusIcon(s.status)} ${statusText(s)}${s.error ? ` — ${s.error}` : ""}`);
60
+ }
61
+ }
62
+ for (const err of errors) {
63
+ lines.push(` ⚠ ${err}`);
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+ /** Wire the /mcp command + subcommands. */
68
+ export function registerMcpPanel(pi, manager, configErrors) {
69
+ const repoRoot = () => resolveProjectRoot(deps.cwd);
70
+ let deps = { cwd: process.cwd(), hasUI: true };
71
+ const persistDisabled = (name, disabled) => {
72
+ const { file } = readUserMcpConfig();
73
+ const root = repoRoot();
74
+ const entry = (file.projects ??= {})[root] ??= {};
75
+ const list = entry.disabledMcpServers ?? [];
76
+ const next = disabled ? [...new Set([...list, name])] : list.filter((n) => n !== name);
77
+ if (next.length !== list.length || disabled)
78
+ entry.disabledMcpServers = next;
79
+ writeUserMcpConfig(file);
80
+ };
81
+ const isPersistedDisabled = (name) => {
82
+ const { file } = readUserMcpConfig();
83
+ const entry = (file.projects ?? {})[repoRoot()];
84
+ return (entry?.disabledMcpServers ?? []).includes(name);
85
+ };
86
+ const refreshForServer = async (serverName) => {
87
+ const server = manager.get(serverName);
88
+ if (server?.client) {
89
+ await registerServerTools(pi, manager, serverName);
90
+ await registerServerPrompts(pi, manager, serverName);
91
+ }
92
+ };
93
+ pi.registerCommand("mcp", {
94
+ description: "Manage MCP servers: status, tools, reconnect, enable/disable.",
95
+ handler: async (args, ctx) => {
96
+ deps = { cwd: ctx.cwd, hasUI: ctx.hasUI };
97
+ const sub = args.trim().split(/\s+/).filter(Boolean);
98
+ // Text paths that work in every mode:
99
+ if (sub[0] === "tools" && sub[1]) {
100
+ ctx.ui.notify(await renderToolsFor(sub[1], manager), "info");
101
+ return;
102
+ }
103
+ if (sub[0] === "reconnect" && sub[1]) {
104
+ await doReconnect(sub[1], ctx.ui);
105
+ return;
106
+ }
107
+ if (sub[0] === "enable" || sub[0] === "disable") {
108
+ await doToggle(sub[0], sub.slice(1), ctx.ui);
109
+ return;
110
+ }
111
+ if (!ctx.hasUI) {
112
+ ctx.ui.notify(renderPanelListing(manager.list(), configErrors()), "info");
113
+ return;
114
+ }
115
+ await interactivePanel(ctx.ui, ctx.signal);
116
+ },
117
+ });
118
+ async function doAuthenticate(name, ui, signal) {
119
+ const server = manager.get(name);
120
+ if (!server) {
121
+ ui.notify(`No MCP server named "${name}".`, "error");
122
+ return;
123
+ }
124
+ ui.notify(`Starting OAuth for ${name} — your browser will open…`, "info");
125
+ const result = await manager.authenticateServer(name, signal);
126
+ if (result?.status === "connected") {
127
+ await refreshForServer(name);
128
+ ui.notify(`✓ ${name} authenticated and connected`, "info");
129
+ }
130
+ else if (result && result.error === undefined && signal?.aborted) {
131
+ // A user-initiated cancel is not an error — quiet, no warning.
132
+ ui.notify(`○ ${name} authentication cancelled`, "info");
133
+ }
134
+ else {
135
+ ui.notify(`⚠ ${name} authentication did not complete${result?.error ? ` — ${result.error}` : ""}`, "warning");
136
+ }
137
+ }
138
+ async function doReconnect(name, ui) {
139
+ const server = manager.get(name);
140
+ if (!server) {
141
+ ui.notify(`No MCP server named "${name}".`, "error");
142
+ return;
143
+ }
144
+ ui.notify(`Reconnecting ${name}…`, "info");
145
+ const result = await manager.reconnect(name);
146
+ if (result?.status === "connected") {
147
+ await refreshForServer(name);
148
+ ui.notify(`✓ ${name} reconnected`, "info");
149
+ }
150
+ else {
151
+ ui.notify(`✗ ${name} failed to reconnect${result?.error ? ` — ${result.error}` : ""}`, "error");
152
+ }
153
+ }
154
+ async function doToggle(action, names, ui) {
155
+ const targets = names.length > 0 ? names : manager.list().map((s) => s.name);
156
+ if (names.length === 0 && targets.length === 0) {
157
+ ui.notify("No MCP servers configured.", "warning");
158
+ return;
159
+ }
160
+ for (const name of targets) {
161
+ const server = manager.get(name);
162
+ if (!server) {
163
+ ui.notify(`No MCP server named "${name}".`, "error");
164
+ continue;
165
+ }
166
+ if (action === "disable") {
167
+ await manager.disconnect(name);
168
+ persistDisabled(name, true);
169
+ ui.notify(`○ ${name} disabled`, "info");
170
+ }
171
+ else {
172
+ persistDisabled(name, false);
173
+ const result = await manager.connect(name);
174
+ if (result?.status === "connected") {
175
+ await refreshForServer(name);
176
+ ui.notify(`✓ ${name} enabled and connected`, "info");
177
+ }
178
+ else {
179
+ ui.notify(`⚠ ${name} enabled but failed to connect${result?.error ? ` — ${result.error}` : ""}`, "warning");
180
+ }
181
+ }
182
+ }
183
+ }
184
+ async function renderToolsFor(name, m) {
185
+ const server = m.get(name);
186
+ if (!server)
187
+ return `No MCP server named "${name}".`;
188
+ if (!server.client)
189
+ return `${name} is ${statusText(server)} — no tools available (try /mcp reconnect ${name}).`;
190
+ try {
191
+ const list = await server.client.listTools();
192
+ if (!list.tools?.length)
193
+ return `${name} exposes no tools.`;
194
+ return [
195
+ `${name} — ${list.tools.length} tool(s):`,
196
+ ...list.tools.map((t) => ` mcp__${name}__${t.name}${t.description ? ` — ${t.description.slice(0, 120)}` : ""}`),
197
+ ].join("\n");
198
+ }
199
+ catch (err) {
200
+ return `${name}: listTools failed — ${err instanceof Error ? err.message : String(err)}`;
201
+ }
202
+ }
203
+ async function interactivePanel(ui, signal) {
204
+ // Level 1: server list
205
+ const servers = manager.list();
206
+ if (servers.length === 0) {
207
+ ui.notify(renderPanelListing(servers, configErrors()), "info");
208
+ return;
209
+ }
210
+ const options = servers.map((s) => `${s.name} · ${statusIcon(s.status)} ${statusText(s)}`);
211
+ const choice = await ui.select("MCP servers", [...options, "Exit"], signal ? { signal } : undefined);
212
+ if (!choice || choice === "Exit")
213
+ return;
214
+ const name = choice.split(" ·")[0];
215
+ await serverDetail(name, ui, signal);
216
+ }
217
+ async function serverDetail(name, ui, signal) {
218
+ const server = manager.get(name);
219
+ if (!server)
220
+ return;
221
+ const header = `${name} · ${statusIcon(server.status)} ${statusText(server)}`;
222
+ const actions = ["View tools", "Reconnect", "Back"];
223
+ if (server.status === "disabled" || isPersistedDisabled(name))
224
+ actions.unshift("Enable");
225
+ else
226
+ actions.unshift("Disable");
227
+ if (server.status === "needs_auth")
228
+ actions.unshift("Authenticate");
229
+ const choice = await ui.select(header, actions, signal ? { signal } : undefined);
230
+ if (!choice || choice === "Back")
231
+ return;
232
+ switch (choice) {
233
+ case "Authenticate":
234
+ await doAuthenticate(name, ui, signal);
235
+ return;
236
+ case "View tools":
237
+ ui.notify(await renderToolsFor(name, manager), "info");
238
+ return;
239
+ case "Reconnect":
240
+ await doReconnect(name, ui);
241
+ return;
242
+ case "Enable":
243
+ await doToggle("enable", [name], ui);
244
+ return;
245
+ case "Disable":
246
+ await doToggle("disable", [name], ui);
247
+ return;
248
+ }
249
+ await serverDetail(name, ui, signal);
250
+ }
251
+ }
252
+ /** Read the persisted disabled list for a repo root (exported for tests). */
253
+ export function readDisabledServers(repoRoot) {
254
+ const { file } = readUserMcpConfig();
255
+ const entry = (file.projects ?? {})[repoRoot];
256
+ return (entry?.disabledMcpServers ?? []).slice();
257
+ }
258
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * MCP prompts as dynamically-registered pi slash commands: every prompt a
3
+ * server exposes becomes `/mcp__<server>__<prompt> [args]` whose handler
4
+ * fetches the rendered messages and injects them as the next user turn via
5
+ * pi.sendUserMessage. pi prompt templates are file-only, so runtime
6
+ * registration is the only path — commands are resolved live on each
7
+ * getRegisteredCommands() call.
8
+ */
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import type { McpManager } from "./manager.js";
11
+ export interface PromptRegistrationResult {
12
+ commands: {
13
+ command: string;
14
+ serverName: string;
15
+ promptName: string;
16
+ description: string;
17
+ }[];
18
+ warnings: string[];
19
+ }
20
+ export declare function registerServerPrompts(pi: ExtensionAPI, manager: McpManager, serverName: string): Promise<PromptRegistrationResult>;
21
+ /** "a=1 b=two" → { a: "1", b: "two" }; quotes group spaces. */
22
+ export declare function parsePromptArgs(args: string): Record<string, string>;
23
+ //# sourceMappingURL=prompts.d.ts.map