Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions profiles/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions profiles/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 29 additions & 13 deletions src/commands/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1633,7 +1633,7 @@ export async function run(args: string[]): Promise<number> {
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);
Expand All @@ -1652,6 +1652,23 @@ export async function run(args: string[]): Promise<number> {
let kept: Set<string> | 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
Expand Down Expand Up @@ -1681,34 +1698,33 @@ export async function run(args: string[]): Promise<number> {
);
}
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`,
);
}
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/mcp-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
autoPrunableMcps,
autoPruneEnabled,
mcpPruneMode,
isRecognizedPruneEnv,
readRuntimeMcpServerIds,
} from "./mcp-overrides";
import { writeFile as writeFileAsync } from "node:fs/promises";
Expand Down Expand Up @@ -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);
}
});
});
35 changes: 28 additions & 7 deletions src/lib/mcp-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down
38 changes: 38 additions & 0 deletions src/lib/profile-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
26 changes: 26 additions & 0 deletions src/lib/profile-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
InheritanceDepthExceeded,
type MCPRef,
type NpxSkillRef,
type McpPruneMode,
type PluginRef,
type Profile,
ProfileError,
Expand Down Expand Up @@ -278,6 +279,21 @@ function normalizePluginRef(raw: PluginRef): ResolvedPlugin {
// Deep-merge helpers
// ---------------------------------------------------------------------------

const PRUNE_RANK: Record<McpPruneMode, number> = { 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<T extends string>(
parent: T[] | undefined,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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] },
Expand Down Expand Up @@ -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: {
Expand Down
Loading