@yagni-app/code-staging 1.0.5-staging.1234.1 → 1.0.5-staging.1238.1

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 (40) hide show
  1. package/dist/claudePlugins.d.ts +3 -1
  2. package/dist/claudePlugins.js +3 -1
  3. package/dist/cli.js +7 -0
  4. package/dist/doctor.d.ts +20 -0
  5. package/dist/doctor.js +83 -0
  6. package/dist/extension/index.d.ts +5 -5
  7. package/dist/extension/index.js +69 -20
  8. package/dist/extension/mcp/approval.d.ts +45 -0
  9. package/dist/extension/mcp/approval.js +164 -0
  10. package/dist/extension/mcp/auth.d.ts +123 -0
  11. package/dist/extension/mcp/auth.js +547 -0
  12. package/dist/extension/mcp/authStore.d.ts +61 -0
  13. package/dist/extension/mcp/authStore.js +105 -0
  14. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  15. package/dist/extension/mcp/cliConfig.js +12 -0
  16. package/dist/extension/mcp/config.d.ts +130 -0
  17. package/dist/extension/mcp/config.js +265 -0
  18. package/dist/extension/mcp/log.d.ts +28 -0
  19. package/dist/extension/mcp/log.js +82 -0
  20. package/dist/extension/mcp/manager.d.ts +94 -0
  21. package/dist/extension/mcp/manager.js +233 -0
  22. package/dist/extension/mcp/names.d.ts +25 -0
  23. package/dist/extension/mcp/names.js +40 -0
  24. package/dist/extension/mcp/panel.d.ts +34 -0
  25. package/dist/extension/mcp/panel.js +258 -0
  26. package/dist/extension/mcp/prompts.d.ts +23 -0
  27. package/dist/extension/mcp/prompts.js +93 -0
  28. package/dist/extension/mcp/startup.d.ts +55 -0
  29. package/dist/extension/mcp/startup.js +149 -0
  30. package/dist/extension/mcp/tools.d.ts +31 -0
  31. package/dist/extension/mcp/tools.js +117 -0
  32. package/dist/extension/mcp/transports.d.ts +17 -0
  33. package/dist/extension/mcp/transports.js +44 -0
  34. package/dist/extension/permission/gate.d.ts +7 -0
  35. package/dist/extension/permission/gate.js +4 -2
  36. package/dist/mcpCommand.d.ts +105 -0
  37. package/dist/mcpCommand.js +730 -0
  38. package/package.json +3 -2
  39. package/dist/extension/mcpTools.d.ts +0 -57
  40. package/dist/extension/mcpTools.js +0 -132
@@ -18,7 +18,9 @@
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
20
  * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers, LSP servers, themes, output styles. Discovery is read-only.
21
+ * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
+ * `mcp/` module — see `yagni mcp --help`), LSP servers, themes, output styles.
23
+ * Discovery is read-only.
22
24
  *
23
25
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
24
26
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -18,7 +18,9 @@
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
20
  * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers, LSP servers, themes, output styles. Discovery is read-only.
21
+ * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
+ * `mcp/` module — see `yagni mcp --help`), LSP servers, themes, output styles.
23
+ * Discovery is read-only.
22
24
  *
23
25
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
24
26
  * entries degrade to "that plugin absent" — never a failed launch. Path
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ import { agentDir, credentialsDir, piPackageDir } from "./credentials.js";
23
23
  import { DISTRIBUTION } from "./distribution.js";
24
24
  import { connectCommand } from "./connectClaudeCode.js";
25
25
  import { goCommand } from "./goHeadless.js";
26
+ import { mcpCommand } from "./mcpCommand.js";
26
27
  import { login } from "./login.js";
27
28
  import { logout } from "./logout.js";
28
29
  import { tokenCommand } from "./token.js";
@@ -653,6 +654,12 @@ export async function main(argv) {
653
654
  if (command === "connect") {
654
655
  return connectCommand(rest);
655
656
  }
657
+ // MCP server configuration: same three-scope model and file homes the
658
+ // in-session /mcp panel uses, shared code via the bundled extension's
659
+ // config entry. `list` runs real health-check connections.
660
+ if (command === "mcp") {
661
+ return mcpCommand(rest);
662
+ }
656
663
  // The headless pipeline entry. `go` is a real subcommand, not passthrough:
657
664
  // the interactive run stays `/go` inside a session, and a `yagni go` without
658
665
  // --headless is refused with usage rather than launched as an agent prompt.
package/dist/doctor.d.ts CHANGED
@@ -67,6 +67,24 @@ export declare function checkGh(onPath: boolean): CheckResult;
67
67
  * most machines have no collector, and that is the healthy default.
68
68
  */
69
69
  export declare function checkOtelExport(config: OtelLaunchConfig | undefined): CheckResult;
70
+ /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
71
+ export interface McpProbe {
72
+ /** YAGNI_CODE_MCP_DISABLED kill-switch is active. */
73
+ disabled: boolean;
74
+ /** Count of configured servers (post-merge, before the approval gate). */
75
+ serverCount: number;
76
+ /** Project-scope servers that are neither enabled nor disabled. */
77
+ undecidedProjectServers: string[];
78
+ /** Config parse/validation errors (already human-readable). */
79
+ errors: string[];
80
+ }
81
+ /**
82
+ * Advisory (never required) MCP line: reports config health without connecting
83
+ * to anything — `yagni mcp list`/`get` already run the real health checks, and
84
+ * doctor must never spawn an unapproved server process. The kill-switch and
85
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
86
+ */
87
+ export declare function checkMcpConfig(probe: McpProbe): CheckResult;
70
88
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
71
89
  export interface BashProbe {
72
90
  found: boolean;
@@ -95,6 +113,8 @@ export interface DoctorDeps {
95
113
  probeBash?: () => BashProbe;
96
114
  currentVersion?: string;
97
115
  probeLatestVersion?: () => Promise<string | null>;
116
+ /** MCP config snapshot (config-only, no connections). */
117
+ probeMcp?: () => Promise<McpProbe>;
98
118
  log?: (msg: string) => void;
99
119
  }
100
120
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
package/dist/doctor.js CHANGED
@@ -20,6 +20,7 @@ import { classifyTokenExpiry } from "./launch.js";
20
20
  import { resolveOtelLaunchWithWorkspace } from "./otel.js";
21
21
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
+ import { resolveMcpConfigPath } from "./mcpCommand.js";
23
24
  // ── Pure check builders ─────────────────────────────────────────────────────
24
25
  export function checkPiEngine(probe) {
25
26
  if (!probe.binPath || !probe.binExists) {
@@ -225,6 +226,55 @@ export function checkOtelExport(config) {
225
226
  required: false,
226
227
  };
227
228
  }
229
+ /**
230
+ * Advisory (never required) MCP line: reports config health without connecting
231
+ * to anything — `yagni mcp list`/`get` already run the real health checks, and
232
+ * doctor must never spawn an unapproved server process. The kill-switch and
233
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
234
+ */
235
+ export function checkMcpConfig(probe) {
236
+ if (probe.disabled) {
237
+ return {
238
+ name: "mcp servers",
239
+ status: "warn",
240
+ detail: "disabled (YAGNI_CODE_MCP_DISABLED=1)",
241
+ hint: "unset YAGNI_CODE_MCP_DISABLED to re-enable MCP servers",
242
+ required: false,
243
+ };
244
+ }
245
+ if (probe.errors.length > 0) {
246
+ return {
247
+ name: "mcp servers",
248
+ status: "fail",
249
+ detail: `${probe.serverCount} configured, ${probe.errors.length} config error${probe.errors.length === 1 ? "" : "s"}`,
250
+ hint: `run \`yagni mcp list\` (first error: ${probe.errors[0]})`,
251
+ required: false,
252
+ };
253
+ }
254
+ if (probe.serverCount === 0) {
255
+ return {
256
+ name: "mcp servers",
257
+ status: "ok",
258
+ detail: "none configured",
259
+ required: false,
260
+ };
261
+ }
262
+ if (probe.undecidedProjectServers.length > 0) {
263
+ return {
264
+ name: "mcp servers",
265
+ status: "warn",
266
+ detail: `${probe.serverCount} configured, ${probe.undecidedProjectServers.length} awaiting approval`,
267
+ hint: "run /mcp to approve the project servers",
268
+ required: false,
269
+ };
270
+ }
271
+ return {
272
+ name: "mcp servers",
273
+ status: "ok",
274
+ detail: `${probe.serverCount} configured`,
275
+ required: false,
276
+ };
277
+ }
228
278
  export function checkBash(probe) {
229
279
  if (!probe.found) {
230
280
  return {
@@ -312,6 +362,37 @@ function defaultProbeStateDir() {
312
362
  return { path, exists: false, mode: null };
313
363
  }
314
364
  }
365
+ async function defaultProbeMcp(env = process.env) {
366
+ const disabled = env.YAGNI_CODE_MCP_DISABLED === "1";
367
+ if (disabled)
368
+ return { disabled: true, serverCount: 0, undecidedProjectServers: [], errors: [] };
369
+ try {
370
+ const mod = (await import(resolveMcpConfigPath()));
371
+ if (typeof mod?.loadMcpServers !== "function") {
372
+ return { disabled: false, serverCount: 0, undecidedProjectServers: [], errors: ["extension MCP config entry missing"] };
373
+ }
374
+ const loaded = mod.loadMcpServers(process.cwd(), env);
375
+ const repoRoot = mod.resolveProjectRoot(process.cwd());
376
+ const { state } = mod.readProjectApproval(repoRoot);
377
+ const undecided = loaded.servers
378
+ .filter((s) => s.scope === "project" && mod.decisionFor(state, s.name) === "undecided")
379
+ .map((s) => s.name);
380
+ return {
381
+ disabled: false,
382
+ serverCount: loaded.servers.length,
383
+ undecidedProjectServers: undecided,
384
+ errors: loaded.errors.map((e) => (e.serverName ? `${e.serverName}: ${e.message}` : e.message)),
385
+ };
386
+ }
387
+ catch (err) {
388
+ return {
389
+ disabled: false,
390
+ serverCount: 0,
391
+ undecidedProjectServers: [],
392
+ errors: [err instanceof Error ? err.message : String(err)],
393
+ };
394
+ }
395
+ }
315
396
  async function defaultProbeBackend(baseUrl, token) {
316
397
  if (!token)
317
398
  return { kind: "skipped" };
@@ -378,6 +459,7 @@ export async function gatherChecks(deps = {}) {
378
459
  const readProfile = deps.readActiveProfile ?? readActiveProfile;
379
460
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
380
461
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
462
+ const probeMcp = deps.probeMcp ?? (() => defaultProbeMcp());
381
463
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
382
464
  const platform = deps.platform ?? process.platform;
383
465
  const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
@@ -406,6 +488,7 @@ export async function gatherChecks(deps = {}) {
406
488
  checks.push(checkBackend(backend));
407
489
  checks.push(checkStateDir(probeStateDir()));
408
490
  checks.push(checkGh(ghOnPath()));
491
+ checks.push(checkMcpConfig(await probeMcp()));
409
492
  checks.push(checkOtelExport(await resolveOtelLaunchWithWorkspace({
410
493
  env: process.env,
411
494
  cwd: process.cwd(),
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
- import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
4
+ import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
5
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
6
  import { type TokenProvider } from "./tokenProvider.js";
7
7
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -53,11 +53,11 @@ export interface RegisterYagniDeps {
53
53
  /** The beat's once-per-repo marker store. Injectable (no disk in tests). */
54
54
  mineBeatMarkers?: MineBeatMarkers;
55
55
  /**
56
- * Workspace MCP server list seam (YAG-446). The default hits
57
- * `/api/yagni-code/mcp/servers` and is fail-soft: older backends and
58
- * un-rescoped tokens yield `[]`, never a startup error.
56
+ * Local-MCP startup seam: injectable so tests assert the wiring (approval
57
+ * skip, kill-switch, mutating-tool gate) without touching real config
58
+ * homes or spawning servers. Default runs the real startup.
59
59
  */
60
- fetchMcpServers?: typeof defaultFetchMcpServers;
60
+ startMcp?: typeof defaultStartMcp;
61
61
  /**
62
62
  * The CLI init pass (Onramp Door B). Injectable so tests assert it fires on a
63
63
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
@@ -29,7 +29,8 @@ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_
29
29
  import { RerouteNotifier } from "./rerouteNotice.js";
30
30
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
31
31
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
32
- import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
32
+ import { looksMutating } from "./mcp/tools.js";
33
+ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNotices } from "./mcp/startup.js";
33
34
  import { registerGoCommand } from "./pipeline/goCommand.js";
34
35
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
35
36
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
@@ -293,16 +294,65 @@ export async function registerYagni(pi, deps = {}) {
293
294
  const decisionCapture = evalMode ? undefined : makeDecisionCapture(decisionClientOpts);
294
295
  if (!evalMode)
295
296
  registerDecisionCommands(pi, decisionClientOpts);
296
- // Workspace MCP servers (YAG-446): register one tool per enabled server
297
- // tool, executing through the backend proxy, plus the /mcp listing
298
- // command. Fail-soft fetch (older backend / un-rescoped token no MCP
299
- // tools); skipped in eval mode like the other external-side-effect tools.
300
- const fetchMcp = deps.fetchMcpServers ?? defaultFetchMcpServers;
301
- const mcpServers = evalMode
302
- ? []
303
- : await fetchMcp({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
304
- const { mutatingToolNames: mcpMutatingTools } = registerMcpTools(pi, mcpServers, { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
305
- registerMcpCommand(pi, mcpServers, { baseUrl });
297
+ // Local MCP servers (standard client, replaces the backend-proxy /mcp of
298
+ // the YAG-446 era): load user/project/local config, approval-gate project
299
+ // servers, connect, register one tool per server tool plus /mcp panel.
300
+ // Fail-soft everywhere; skipped entirely in eval mode (external side
301
+ // effects) and under the YAGNI_CODE_MCP_DISABLED kill-switch.
302
+ const startMcpImpl = deps.startMcp ?? defaultStartMcp;
303
+ // One-shot notices queued by startMcp at session start (the `.mcp.json`
304
+ // approval prompt); flushed once on the first provider response. Connectivity
305
+ // notices are NOT here they are recomputed live at flush (see below).
306
+ const mcpApprovalNotices = [];
307
+ const mcpOutcome = evalMode
308
+ ? { manager: undefined, mutatingToolNames: [] }
309
+ : await startMcpImpl(pi, {
310
+ cwd: process.cwd(),
311
+ env,
312
+ hasUI: true,
313
+ notify: (msg) => mcpApprovalNotices.push(msg),
314
+ });
315
+ const mcpMutatingSet = new Set(mcpOutcome.mutatingToolNames);
316
+ // Wire names carry the original tool name after the second `__`: parse it
317
+ // out and re-run the heuristic for tools registered after startup (a
318
+ // reconnect can re-register tools the original classification never saw).
319
+ const mcpStartupIsMutating = (wireName) => {
320
+ const parts = wireName.split("__");
321
+ const original = parts.slice(2).join("__");
322
+ if (!original)
323
+ return false;
324
+ return looksMutating(original);
325
+ };
326
+ if (!evalMode && mcpOutcome.manager) {
327
+ wireShutdown(pi, mcpOutcome.manager);
328
+ // Startup notify (documented CC deviation): one quiet line per notice on
329
+ // the first provider response — never raw stdout, never blocking.
330
+ //
331
+ // Connectivity notices are recomputed against LIVE manager state at flush
332
+ // time (not the frozen startMcp snapshot): if the user healed a server via
333
+ // /mcp before the first turn, it must no longer read as broken/unauth.
334
+ // The static queue only carries non-connectivity notices (e.g. the
335
+ // `.mcp.json` approval prompt), which stay one-shot.
336
+ let connectivityNoticesFlushed = false;
337
+ pi.on("after_provider_response", (_event, ctx) => {
338
+ try {
339
+ if (!ctx.hasUI)
340
+ return;
341
+ const connectivity = connectivityNoticesFlushed
342
+ ? []
343
+ : deriveStartupConnectivityNotices(mcpOutcome.manager);
344
+ connectivityNoticesFlushed = true;
345
+ for (const notice of connectivity)
346
+ ctx.ui.notify(notice, "info");
347
+ while (mcpApprovalNotices.length > 0) {
348
+ ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
+ }
350
+ }
351
+ catch {
352
+ // A notice must never break a turn.
353
+ }
354
+ });
355
+ }
306
356
  // P3 + W4: interactive permission tiers + plan mode via /mode, now with a
307
357
  // session bless store (three-way review-mode select) and a capture hook that
308
358
  // drafts a decision on "don't ask again". Default auto, so still additive.
@@ -461,18 +511,17 @@ export async function registerYagni(pi, deps = {}) {
461
511
  }
462
512
  })();
463
513
  },
464
- ...(mcpMutatingTools.length > 0
514
+ ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
465
515
  ? {
466
516
  policy: {
467
- planBlockTools: [
468
- ...DEFAULT_PERMISSION_POLICY.planBlockTools,
469
- ...mcpMutatingTools,
470
- ],
471
- reviewConfirmTools: [
472
- ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
473
- ...mcpMutatingTools,
474
- ],
517
+ planBlockTools: [...DEFAULT_PERMISSION_POLICY.planBlockTools],
518
+ reviewConfirmTools: [...DEFAULT_PERMISSION_POLICY.reviewConfirmTools],
475
519
  alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
520
+ // Predicate (not a static spread): matches any registered
521
+ // mcp__* tool that startup classified as mutating, including
522
+ // tools re-registered after a /mcp reconnect.
523
+ isMutating: (toolName) => mcpMutatingSet.has(toolName) ||
524
+ (toolName.startsWith("mcp__") && mcpStartupIsMutating(toolName)),
476
525
  },
477
526
  }
478
527
  : {}),
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Approval gate for project-scope `.mcp.json` servers, mirroring Claude Code's
3
+ * decision shape: a `.mcp.json` in the repo (shared, VCS-controlled) cannot
4
+ * spawn processes until the user has approved it. Choices live in the
5
+ * per-project entry of `~/.yagni-code/mcp.json` under Claude Code's exact key
6
+ * names (`enabledMcpjsonServers` / `disabledMcpjsonServers` /
7
+ * `enableAllProjectMcpServers`), so a decision made in one tool is respected
8
+ * by the other.
9
+ */
10
+ import { McpConfigError, UserMcpProjectEntry } from "./config.js";
11
+ export type ProjectApprovalDecision = "enabled" | "disabled" | "undecided";
12
+ export interface ProjectApprovalState {
13
+ enabledMcpjsonServers: string[];
14
+ disabledMcpjsonServers: string[];
15
+ enableAllProjectMcpServers: boolean;
16
+ }
17
+ /** Read the approval state for one project (abs path). Missing file/entry → fresh state. */
18
+ export declare function readProjectApproval(repoRoot: string): {
19
+ state: ProjectApprovalState;
20
+ errors: McpConfigError[];
21
+ };
22
+ /** Effective decision for one project-scope server. Undecided by default. */
23
+ export declare function decisionFor(state: ProjectApprovalState, serverName: string): ProjectApprovalDecision;
24
+ /** Which configured project servers await a decision (drives the session-start prompt). */
25
+ export declare function undecidedProjectServers(state: ProjectApprovalState, projectServerNames: string[]): string[];
26
+ /**
27
+ * Record a decision. `enableAll: true` mirrors Claude Code's "Yes, approve all
28
+ * project servers in this repo" (sets the blanket flag); a single enable/disable
29
+ * appends to the corresponding list. Idempotent.
30
+ */
31
+ export declare function recordProjectDecision(repoRoot: string, serverName: string | "all", decision: "enabled" | "disabled"): {
32
+ errors: McpConfigError[];
33
+ };
34
+ /** Clear this project's `.mcp.json` decisions (`yagni mcp reset-project-choices`). */
35
+ export declare function resetProjectChoices(repoRoot: string): {
36
+ errors: McpConfigError[];
37
+ };
38
+ /**
39
+ * Mutate `~/.yagni-code/mcp.json`'s per-project entry under a read-modify-write
40
+ * helper shared with CLI commands (add/remove at local scope).
41
+ */
42
+ export declare function updateProjectEntry(repoRoot: string, mutate: (entry: UserMcpProjectEntry) => void): {
43
+ errors: McpConfigError[];
44
+ };
45
+ //# sourceMappingURL=approval.d.ts.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Approval gate for project-scope `.mcp.json` servers, mirroring Claude Code's
3
+ * decision shape: a `.mcp.json` in the repo (shared, VCS-controlled) cannot
4
+ * spawn processes until the user has approved it. Choices live in the
5
+ * per-project entry of `~/.yagni-code/mcp.json` under Claude Code's exact key
6
+ * names (`enabledMcpjsonServers` / `disabledMcpjsonServers` /
7
+ * `enableAllProjectMcpServers`), so a decision made in one tool is respected
8
+ * by the other.
9
+ */
10
+ import { mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, writeUserMcpConfig, } from "./config.js";
11
+ /** Read the approval state for one project (abs path). Missing file/entry → fresh state. */
12
+ export function readProjectApproval(repoRoot) {
13
+ const { file, errors } = readUserMcpConfig();
14
+ const entry = (file.projects ?? {})[repoRoot] ?? {};
15
+ return {
16
+ state: {
17
+ enabledMcpjsonServers: entry.enabledMcpjsonServers ?? [],
18
+ disabledMcpjsonServers: entry.disabledMcpjsonServers ?? [],
19
+ enableAllProjectMcpServers: entry.enableAllProjectMcpServers ?? false,
20
+ },
21
+ errors,
22
+ };
23
+ }
24
+ /** Effective decision for one project-scope server. Undecided by default. */
25
+ export function decisionFor(state, serverName) {
26
+ if (state.enableAllProjectMcpServers)
27
+ return "enabled";
28
+ if (state.enabledMcpjsonServers.includes(serverName))
29
+ return "enabled";
30
+ if (state.disabledMcpjsonServers.includes(serverName))
31
+ return "disabled";
32
+ return "undecided";
33
+ }
34
+ /** Which configured project servers await a decision (drives the session-start prompt). */
35
+ export function undecidedProjectServers(state, projectServerNames) {
36
+ return projectServerNames.filter((name) => decisionFor(state, name) === "undecided");
37
+ }
38
+ /**
39
+ * Record a decision. `enableAll: true` mirrors Claude Code's "Yes, approve all
40
+ * project servers in this repo" (sets the blanket flag); a single enable/disable
41
+ * appends to the corresponding list. Idempotent.
42
+ */
43
+ export function recordProjectDecision(repoRoot, serverName, decision) {
44
+ const { file, errors } = readUserMcpConfig();
45
+ if (errors.length > 0)
46
+ return { errors };
47
+ const projects = file.projects ?? {};
48
+ const entry = projects[repoRoot] ?? {};
49
+ if (serverName === "all") {
50
+ if (decision === "enabled") {
51
+ projects[repoRoot] = { ...entry, enableAllProjectMcpServers: true };
52
+ }
53
+ else {
54
+ // "Disable all" clears the blanket flag and marks every current server
55
+ // disabled by name (a new server added to .mcp.json later re-prompts).
56
+ const projectServerNames = currentProjectServerNames(repoRoot);
57
+ projects[repoRoot] = {
58
+ ...entry,
59
+ enableAllProjectMcpServers: false,
60
+ enabledMcpjsonServers: [],
61
+ disabledMcpjsonServers: projectServerNames,
62
+ };
63
+ }
64
+ }
65
+ else {
66
+ const enabled = new Set(entry.enabledMcpjsonServers ?? []);
67
+ const disabled = new Set(entry.disabledMcpjsonServers ?? []);
68
+ if (decision === "enabled") {
69
+ enabled.add(serverName);
70
+ disabled.delete(serverName);
71
+ }
72
+ else {
73
+ disabled.add(serverName);
74
+ enabled.delete(serverName);
75
+ }
76
+ projects[repoRoot] = {
77
+ ...entry,
78
+ enabledMcpjsonServers: [...enabled],
79
+ disabledMcpjsonServers: [...disabled],
80
+ };
81
+ }
82
+ file.projects = projects;
83
+ try {
84
+ writeUserMcpConfig(file);
85
+ return { errors: [] };
86
+ }
87
+ catch (err) {
88
+ return {
89
+ errors: [
90
+ {
91
+ sourcePath: mcpConfigPath(),
92
+ message: `could not persist approval decision (${err instanceof Error ? err.message : String(err)})`,
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ }
98
+ function currentProjectServerNames(repoRoot) {
99
+ // Read fresh so a just-edited .mcp.json is reflected in a "disable all" write.
100
+ return Object.keys(readProjectMcpConfig(repoRoot).servers);
101
+ }
102
+ /** Clear this project's `.mcp.json` decisions (`yagni mcp reset-project-choices`). */
103
+ export function resetProjectChoices(repoRoot) {
104
+ const { file, errors } = readUserMcpConfig();
105
+ if (errors.length > 0)
106
+ return { errors };
107
+ const projects = file.projects ?? {};
108
+ const entry = projects[repoRoot] ?? {};
109
+ delete entry.enabledMcpjsonServers;
110
+ delete entry.disabledMcpjsonServers;
111
+ delete entry.enableAllProjectMcpServers;
112
+ // Keep the entry only if it still carries local servers; otherwise drop it.
113
+ if (Object.keys(entry).length > 0)
114
+ projects[repoRoot] = entry;
115
+ else
116
+ delete projects[repoRoot];
117
+ file.projects = projects;
118
+ try {
119
+ writeUserMcpConfig(file);
120
+ return { errors: [] };
121
+ }
122
+ catch (err) {
123
+ return {
124
+ errors: [
125
+ {
126
+ sourcePath: mcpConfigPath(),
127
+ message: `could not reset project choices (${err instanceof Error ? err.message : String(err)})`,
128
+ },
129
+ ],
130
+ };
131
+ }
132
+ }
133
+ /**
134
+ * Mutate `~/.yagni-code/mcp.json`'s per-project entry under a read-modify-write
135
+ * helper shared with CLI commands (add/remove at local scope).
136
+ */
137
+ export function updateProjectEntry(repoRoot, mutate) {
138
+ const { file, errors } = readUserMcpConfig();
139
+ if (errors.length > 0)
140
+ return { errors };
141
+ const projects = file.projects ?? {};
142
+ const entry = projects[repoRoot] ?? {};
143
+ mutate(entry);
144
+ if (Object.keys(entry).length > 0)
145
+ projects[repoRoot] = entry;
146
+ else
147
+ delete projects[repoRoot];
148
+ file.projects = projects;
149
+ try {
150
+ writeUserMcpConfig(file);
151
+ return { errors: [] };
152
+ }
153
+ catch (err) {
154
+ return {
155
+ errors: [
156
+ {
157
+ sourcePath: mcpConfigPath(),
158
+ message: `could not persist project entry (${err instanceof Error ? err.message : String(err)})`,
159
+ },
160
+ ],
161
+ };
162
+ }
163
+ }
164
+ //# sourceMappingURL=approval.js.map