diff --git a/profiles/_types.ts b/profiles/_types.ts index 3f7d147e..1edfc9cc 100644 --- a/profiles/_types.ts +++ b/profiles/_types.ts @@ -43,11 +43,23 @@ export interface ProfileSkills { // Using it will throw a SchemaViolation. } +/** + * Default MCP prune mode applied at launch when no `CUE_PRUNE_MCPS` env override + * is set. `off` keeps all MCPs (fail-open, the global default); `profile` drops + * unused profile-declared MCPs; `all` also drops unused global servers from the + * runtime .claude.json. Leaf-wins through single inheritance; most-aggressive + * wins across composite parts (off < profile < all). Pruning only ever drops + * MCPs no active skill needs and that aren't pinned, so a higher mode is safe. + */ +export type McpPruneMode = "off" | "profile" | "all"; + export interface Profile { name: string; description: string; icon?: string; iconImage?: string; + /** Default non-interactive prune mode; `CUE_PRUNE_MCPS` overrides per launch. */ + mcpPrune?: McpPruneMode; /** * Target main-session model id (e.g. "claude-opus-4-8"). Advisory only — cue * can't pin the main session model (that's a per-launch `/model` choice) — but diff --git a/profiles/schema.json b/profiles/schema.json index 74c47036..a17d01d7 100644 --- a/profiles/schema.json +++ b/profiles/schema.json @@ -31,6 +31,11 @@ "type": "string", "description": "Advisory target main-session model id (e.g. 'claude-opus-4-8'). cue can't pin the main session model (per-launch /model choice); the model-aware startup budget uses it to resolve the context window for the over-budget warning. Leaf-wins; env CUE_MODEL overrides." }, + "mcpPrune": { + "type": "string", + "enum": ["off", "profile", "all"], + "description": "Default non-interactive MCP prune mode at launch. off (default): keep all MCPs. profile: drop unused profile-declared MCPs. all: also drop unused global servers from the runtime .claude.json. Only ever drops MCPs no active skill needs and that aren't pinned. Leaf-wins through inheritance; most-aggressive wins across composite parts; env CUE_PRUNE_MCPS overrides." + }, "contextWindow": { "type": "integer", "minimum": 1, diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 191b7880..63877570 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1633,7 +1633,7 @@ export async function run(args: string[]): Promise { if (agentKind === "claude-code" && profile.mcps.length > 0) { try { const { getNeededMcps } = await import("../lib/skill-dependencies"); - const { readMcpOverride, writeMcpOverride, mcpFingerprint, reconcileDisabledWithNeeded, autoPrunableMcps, mcpPruneMode, readRuntimeMcpServerIds } = await import("../lib/mcp-overrides"); + const { readMcpOverride, writeMcpOverride, mcpFingerprint, reconcileDisabledWithNeeded, autoPrunableMcps, mcpPruneMode, isRecognizedPruneEnv, readRuntimeMcpServerIds } = await import("../lib/mcp-overrides"); const allMcpIds = profile.mcps.map((m) => m.id); const fingerprint = mcpFingerprint(allMcpIds); @@ -1652,6 +1652,23 @@ export async function run(args: string[]): Promise { let kept: Set | null = null; let reviewed = false; + // Effective non-interactive prune mode: a RECOGNIZED `CUE_PRUNE_MCPS` env + // (including an explicit `off`) overrides the profile's declared default; + // otherwise the profile's `mcpPrune:` applies — this is what makes a heavy + // profile auto-prune with no env var. A non-empty but UNRECOGNIZED env + // (e.g. a typo like `profil`) must NOT silently suppress the profile + // default: warn and fall through, so the typo is a no-op, not a foot-gun. + const pruneEnv = process.env.CUE_PRUNE_MCPS; + const pruneEnvSet = pruneEnv != null && pruneEnv !== ""; + const pruneFromEnv = pruneEnvSet && isRecognizedPruneEnv(pruneEnv); + if (pruneEnvSet && !pruneFromEnv) { + process.stderr.write( + `[cue] CUE_PRUNE_MCPS="${pruneEnv}" not recognized (use off|profile|all) — using the profile default\n`, + ); + } + const pruneMode = pruneFromEnv ? mcpPruneMode(pruneEnv) : (profile.mcpPrune ?? "off"); + const pruneSource = pruneFromEnv ? "CUE_PRUNE_MCPS" : "profile mcpPrune"; + if (parsed.disableMcp.length > 0) { // Non-interactive flag path: drop named ids (pinned ones excepted) for // THIS launch only. `reviewed` stays false so the choice is NOT written @@ -1681,34 +1698,33 @@ export async function run(args: string[]): Promise { ); } kept = keepNonPinned(new Set(keepDisabled)); - } else if (mcpPruneMode(process.env.CUE_PRUNE_MCPS) !== "off") { - // Opt-in non-interactive prune (CUE_PRUNE_MCPS). Self-contained: it - // sets mcpDisabledIds directly (so it can drop GLOBAL servers that - // aren't in profile.mcps and thus invisible to the shared block below) - // and leaves `kept` null to skip that block. Recomputed each launch; - // never persisted, so the picker's remembered overrides stay intact. + } else if (pruneMode !== "off") { + // Non-interactive prune, from CUE_PRUNE_MCPS or the profile's mcpPrune + // default. Self-contained: it sets mcpDisabledIds directly (so it can + // drop GLOBAL servers that aren't in profile.mcps and thus invisible to + // the shared block below) and leaves `kept` null to skip that block. + // Recomputed each launch; never persisted, so the picker's remembered + // overrides stay intact. // // profile mode → drop unused PROFILE MCPs only (cue's invariant that // user-global servers are never touched holds). // all mode → also drop unused GLOBAL servers present in the // runtime .claude.json (the heavy ones a coding - // profile never calls). Louder opt-in: it removes - // config the user set globally. - const mode = mcpPruneMode(process.env.CUE_PRUNE_MCPS); + // profile never calls). Removes config set globally. const universe = [...allMcpIds]; - if (mode === "all") { + if (pruneMode === "all") { const rtClaudeJson = join(configDir(), "runtime", profileName, "claude", ".claude.json"); for (const id of readRuntimeMcpServerIds(rtClaudeJson)) { if (!universe.some((p) => p.toLowerCase() === id.toLowerCase())) universe.push(id); } } const drop = new Set(autoPrunableMcps(universe, pinned, needed.keys())); - debug("launch:mcp-prune", { mode, universe, pinned: [...pinned], needed: [...needed.keys()], drop: [...drop] }); + debug("launch:mcp-prune", { mode: pruneMode, source: pruneSource, universe, pinned: [...pinned], needed: [...needed.keys()], drop: [...drop] }); if (drop.size > 0) { profile = { ...profile, mcps: profile.mcps.filter((m) => !drop.has(m.id.toLowerCase())) }; mcpDisabledIds = [...drop]; process.stderr.write( - `[cue] MCPs: auto-pruned ${drop.size} unused (${[...drop].join(", ")}) · CUE_PRUNE_MCPS=${mode} · --cue-pick-mcps to keep\n`, + `[cue] MCPs: auto-pruned ${drop.size} unused (${[...drop].join(", ")}) · ${pruneSource}=${pruneMode} · --cue-pick-mcps to keep\n`, ); } } diff --git a/src/lib/mcp-overrides.test.ts b/src/lib/mcp-overrides.test.ts index 33fecee5..f877182e 100644 --- a/src/lib/mcp-overrides.test.ts +++ b/src/lib/mcp-overrides.test.ts @@ -11,6 +11,7 @@ import { autoPrunableMcps, autoPruneEnabled, mcpPruneMode, + isRecognizedPruneEnv, readRuntimeMcpServerIds, } from "./mcp-overrides"; import { writeFile as writeFileAsync } from "node:fs/promises"; @@ -186,3 +187,16 @@ describe("readRuntimeMcpServerIds", () => { expect(readRuntimeMcpServerIds(p)).toEqual([]); }); }) + +describe("isRecognizedPruneEnv", () => { + test("recognizes every valid token (all/profile/off spellings)", () => { + for (const v of ["off","0","false","no","","auto","unused","profile","1","true","on","all","global","aggressive"," ALL "]) { + expect(isRecognizedPruneEnv(v), v).toBe(true); + } + }); + test("rejects typos / garbage so the caller can fall through", () => { + for (const v of ["profil","prof","aggresive","yes","2","none","disable"]) { + expect(isRecognizedPruneEnv(v), v).toBe(false); + } + }); +}); diff --git a/src/lib/mcp-overrides.ts b/src/lib/mcp-overrides.ts index 996dcae2..cb41bd91 100644 --- a/src/lib/mcp-overrides.ts +++ b/src/lib/mcp-overrides.ts @@ -19,6 +19,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { dirname, join } from "node:path"; import { configDir } from "./config-paths.ts"; +import type { McpPruneMode } from "../../profiles/_types.ts"; + +export type { McpPruneMode }; /** One directory's MCP override. */ export interface McpOverride { @@ -121,22 +124,40 @@ export function autoPrunableMcps( } /** - * Non-interactive prune mode parsed from `CUE_PRUNE_MCPS`: - * - "off" — default; keep all (fail-open). + * Parse a `CUE_PRUNE_MCPS` value into an {@link McpPruneMode}: + * - "off" — keep all (fail-open). * - "profile" — drop unused PROFILE-declared MCPs only. Preserves cue's * invariant that user-global MCPs are never touched. * - "all" — also drop unused GLOBAL MCPs present in the runtime - * .claude.json (heavy servers a coding profile never calls). A louder, - * explicit opt-in because it deletes config the user set globally. + * .claude.json (heavy servers a coding profile never calls). A louder + * opt-in because it deletes config the user set globally. */ -export type McpPruneMode = "off" | "profile" | "all"; +const PRUNE_ALL_TOKENS = ["all", "global", "aggressive"]; +const PRUNE_PROFILE_TOKENS = ["auto", "unused", "profile", "1", "true", "on"]; +const PRUNE_OFF_TOKENS = ["off", "0", "false", "no", ""]; + export function mcpPruneMode(value: string | undefined): McpPruneMode { const v = (value ?? "").trim().toLowerCase(); - if (["all", "global", "aggressive"].includes(v)) return "all"; - if (["auto", "unused", "profile", "1", "true", "on"].includes(v)) return "profile"; + if (PRUNE_ALL_TOKENS.includes(v)) return "all"; + if (PRUNE_PROFILE_TOKENS.includes(v)) return "profile"; return "off"; } +/** + * Whether `value` is a token `mcpPruneMode` recognizes (any of all/profile/off + * spellings). A non-empty, unrecognized value (a typo like `profil`) returns + * false so the caller can warn and fall through to the profile default instead + * of silently parsing it to "off" and suppressing that default. + */ +export function isRecognizedPruneEnv(value: string): boolean { + const v = value.trim().toLowerCase(); + return ( + PRUNE_ALL_TOKENS.includes(v) || + PRUNE_PROFILE_TOKENS.includes(v) || + PRUNE_OFF_TOKENS.includes(v) + ); +} + /** Back-compat predicate: any prune mode at all (profile or all). */ export function autoPruneEnabled(value: string | undefined): boolean { return mcpPruneMode(value) !== "off"; diff --git a/src/lib/profile-loader.test.ts b/src/lib/profile-loader.test.ts index ce33c96d..eb4cbae6 100644 --- a/src/lib/profile-loader.test.ts +++ b/src/lib/profile-loader.test.ts @@ -613,3 +613,41 @@ describe("core persona_includes fan-out (real profiles)", () => { expect(gstack.personaIncludes).toContain("headroom-compression"); }); }); + +describe("mcpPrune resolution", () => { + test("unset by default", async () => { + await writeProfile("np", "name: np\ndescription: no prune declared\n"); + const r = await loadProfile("np"); + expect(r.mcpPrune).toBeUndefined(); + }); + + test("parsed from a single profile", async () => { + await writeProfile("pp", "name: pp\ndescription: prune all\nmcpPrune: all\n"); + const r = await loadProfile("pp"); + expect(r.mcpPrune).toBe("all"); + }); + + test("leaf wins through single inheritance", async () => { + await writeProfile("base-prune", "name: base-prune\ndescription: base\nmcpPrune: profile\n"); + await writeProfile( + "leaf-prune", + "name: leaf-prune\ndescription: leaf\ninherits: base-prune\nmcpPrune: all\n", + ); + expect((await loadProfile("leaf-prune")).mcpPrune).toBe("all"); + // Child without its own setting inherits the parent's. + await writeProfile("leaf-inherit", "name: leaf-inherit\ndescription: leaf2\ninherits: base-prune\n"); + expect((await loadProfile("leaf-inherit")).mcpPrune).toBe("profile"); + }); + + test("most-aggressive wins across a composite (off < profile < all)", async () => { + await writeProfile("cp-off", "name: cp-off\ndescription: off\n"); + await writeProfile("cp-prof", "name: cp-prof\ndescription: profile\nmcpPrune: profile\n"); + await writeProfile("cp-all", "name: cp-all\ndescription: all\nmcpPrune: all\n"); + expect((await loadProfile("cp-off+cp-prof")).mcpPrune).toBe("profile"); + expect((await loadProfile("cp-prof+cp-all")).mcpPrune).toBe("all"); + expect((await loadProfile("cp-off+cp-all")).mcpPrune).toBe("all"); + // No part declares one → undefined. + await writeProfile("cp-off2", "name: cp-off2\ndescription: off2\n"); + expect((await loadProfile("cp-off+cp-off2")).mcpPrune).toBeUndefined(); + }); +}); diff --git a/src/lib/profile-loader.ts b/src/lib/profile-loader.ts index d91de29b..58704f93 100644 --- a/src/lib/profile-loader.ts +++ b/src/lib/profile-loader.ts @@ -26,6 +26,7 @@ import { InheritanceDepthExceeded, type MCPRef, type NpxSkillRef, + type McpPruneMode, type PluginRef, type Profile, ProfileError, @@ -278,6 +279,21 @@ function normalizePluginRef(raw: PluginRef): ResolvedPlugin { // Deep-merge helpers // --------------------------------------------------------------------------- +const PRUNE_RANK: Record = { off: 0, profile: 1, all: 2 }; + +/** + * Most-aggressive prune mode across composite parts (off < profile < all). + * Returns undefined when no part declares one, so the resolved profile keeps + * `mcpPrune` unset (launcher treats unset as "off" unless env overrides). + */ +function mostAggressivePrune(modes: (McpPruneMode | undefined)[]): McpPruneMode | undefined { + let best: McpPruneMode | undefined; + for (const m of modes) { + if (m && (best === undefined || PRUNE_RANK[m] > PRUNE_RANK[best])) best = m; + } + return best; +} + /** Concat then dedupe primitives, preserving order (parent first, child last). */ function dedupePrimitiveArray( parent: T[] | undefined, @@ -448,6 +464,8 @@ function foldChain(chain: Profile[]): ResolvedProfile { // context window overrides the parent; otherwise it inherits. model: child.model ?? acc.model, contextWindow: child.contextWindow ?? acc.contextWindow, + // Prune mode is leaf-wins through single inheritance, same as model. + mcpPrune: child.mcpPrune ?? acc.mcpPrune, // agents: arrays merge by dedupe; if neither parent nor child declares // agents we fall back to the default at the end. agents: dedupePrimitiveArray( @@ -510,6 +528,7 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile { iconImage: p.iconImage, model: p.model, contextWindow: p.contextWindow, + mcpPrune: p.mcpPrune, agents: p.agents && p.agents.length > 0 ? [...p.agents] : [], inherits: p.inherits, skills: { @@ -608,6 +627,10 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf // First part that declares a budget hint wins for the composite. model: parts.find((p) => p.model)?.model, contextWindow: parts.find((p) => p.contextWindow)?.contextWindow, + // Most-aggressive prune mode across parts wins (off < profile < all): adding + // a part that opts into pruning enables it. Safe — prune only drops unused, + // non-pinned MCPs, so a higher mode can never starve a part's skill. + mcpPrune: mostAggressivePrune(parts.map((p) => p.mcpPrune)), agents: [...head.agents] as ResolvedProfile["agents"], inherits: undefined, skills: { local: [...head.skills.local], npx: [...head.skills.npx] }, @@ -646,6 +669,9 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf iconImage: acc.iconImage ?? next.iconImage, model: acc.model ?? next.model, contextWindow: acc.contextWindow ?? next.contextWindow, + // Already the most-aggressive across all parts (computed in the initial + // acc); preserve it rather than recomputing per fold step. + mcpPrune: acc.mcpPrune, agents: dedupePrimitiveArray(acc.agents, next.agents) as ResolvedProfile["agents"], inherits: undefined, skills: {