pi-profile-switch 0.13.2 → 0.14.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/README.md CHANGED
@@ -60,6 +60,21 @@ A profile that uses every field:
60
60
 
61
61
  How the fields behave:
62
62
 
63
+ - `skills`, `extensions`, `mcps`, `tools` take names or globs (e.g. `"internal-*"`) referencing resources you already installed or configured — profiles never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed.
64
+ - `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-contributed tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`. When a profile declares `tools` and at least one MCP server is enabled, Pi's native MCP discovery entry points (`codemode` and `tool_search`) stay active even if you did not list them; unrelated non-MCP tools excluded by `tools` stay excluded.
65
+ - `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are not accepted. An omitted server keeps native access to all its tools; a nonempty array allows only matched tools; an empty array (`[]`) denies all tools for that server while leaving it enabled. Unmatched selectors remain restrictive and are not diagnosed, so confirm selectors with the server before writing them.
66
+ - **Migration notes:**
67
+ - Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
68
+ - Prefixed selectors (e.g. `<server>_<tool>` forms used by previous MCP integrations) no longer apply; replace them with the literal tool names from Pi's built-in MCP extension.
69
+ - `mcps` references servers from your Pi user-level MCP configuration (`~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, and `<agentDir>/mcp.json`); connection details stay in those files.
70
+ - **Omitting `mcps`** leaves all discovered user-level servers at their normal availability.
71
+ - **`mcps: []`** disables every discovered user-level server, including agentDir-only servers; every unselected user-level server keeps its full definition and is explicitly marked `enabled: false` in the generated instance `mcp.json`. Project-level servers are never narrowed.
72
+ - Trusted project-level MCP servers are always kept enabled and are never narrowed by `mcps`.
73
+ - Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
74
+ - A later user-level source replaces a same-named server from an earlier source in full (no field-wise merging), so connection and credential fields are never inherited across files.
75
+ - A profile that declares neither `mcps` nor `mcp_tools` treats a malformed user-level MCP source as a non-fatal diagnostic (printed on stderr with the file path) and starts with the remaining valid sources. Declaring `mcps` or a nonempty `mcp_tools` makes the same malformed source fail activation, because the allowlist cannot be trusted.
76
+ - The instance `mcp.json` is always a generated snapshot of the merged user-level configuration. In-session `pi mcp add` edits the instance copy, and the next `/profile use` or `/profile reload` overwrites it with the profile's snapshot.
77
+ - Any field you omit keeps plain Pi behavior.
63
78
  - `label` and `description` are display metadata. `defaultProvider` and `defaultModel` (declared together) set the startup model; `defaultThinkingLevel` sets its thinking level; `instructions` is appended to the system prompt.
64
79
  - `skills`, `extensions`, `mcps`, and `tools` reference installed resources by name or glob; profiles never copy resources. `tools` covers non-MCP tools only (built-ins and extension tools).
65
80
  - `mcp_tools` selects tools inside MCP servers by literal server and tool name — globs are rejected. Omit a server to leave it unchanged, use `[]` to deny all of its tools while keeping the server enabled, or list names to allow only those. A literal selector that matches nothing stays restrictive without warning; a server that is unknown, disabled, or project-only fails activation with candidates.
@@ -82,6 +97,7 @@ The instance's `mcp.json` is generated by the launcher. Running `pi mcp add` ins
82
97
  | `/profile overlay disable\|enable skill\|extension\|mcp\|tool <name-or-glob>` | Narrow or restore resources for this session only. |
83
98
  | `/profile overlay clear` | Drop the overlay and use the profile as written. |
84
99
 
100
+ All forms work in every mode, including non-interactive ones (`--mode rpc|text|json`); the bare selector degrades to the profile list where no interactive UI exists. The overlay is a runtime-only narrowing: it is never written to a catalog file and never survives a restart. Tools follow the same disable/enable model as the other resource kinds: a tool `disable` entry narrows the profile's resolved tool references — or the runtime's full available tool set when the profile declares no `tools`.
85
101
  All of these work in every mode, including non-interactive ones (`--mode text`, `--mode json`, `--mode rpc`). An overlay lives only in the current runtime: it is never written to your profile files and is gone after a restart. Disabling MCP servers with an overlay is not possible on the built-in `default` profile — it has no server list to narrow.
86
102
 
87
103
  ## Docs
package/bin/pi-profile.ts CHANGED
@@ -67,6 +67,9 @@ try {
67
67
  console.error(`pi-profile: warning: ${warning}`);
68
68
  }
69
69
  const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectDir });
70
+ for (const warning of generated.warnings) {
71
+ console.error(`pi-profile: warning: ${warning}`);
72
+ }
70
73
  process.exitCode = await spawnPi({
71
74
  generated,
72
75
  piArgs: args.piArgs,
@@ -224,7 +224,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
224
224
  const mcpDiscovery = await loadMergedMcpServers(
225
225
  plan.agentDir,
226
226
  projectTrusted ? ctx.cwd : undefined,
227
+ {
228
+ invalidSource:
229
+ plan.mcps !== undefined ||
230
+ (plan.mcpTools !== undefined && Object.keys(plan.mcpTools).length > 0)
231
+ ? "throw"
232
+ : "diagnose",
233
+ },
227
234
  );
235
+ for (const diagnostic of mcpDiscovery.diagnostics ?? []) {
236
+ notify(diagnostic, "warning");
237
+ }
228
238
  const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
229
239
  const disabledMcpServers = discoveredMcpServers.filter(
230
240
  (server) => mcpDiscovery.servers[server]?.enabled === false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -169,7 +169,7 @@ export async function resolveInitialProfile(
169
169
  (options?.overlay?.disabledMcps?.length ?? 0) > 0,
170
170
  );
171
171
  const mcpDiscovery = needsMcp
172
- ? await loadMergedMcpServers(context.agentDir, projectDir)
172
+ ? await loadMergedMcpServers(context.agentDir, projectDir, { invalidSource: "throw" })
173
173
  : undefined;
174
174
 
175
175
  const plan = await resolveProfile({
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * The spawned pi gets: the pi-profile extension via `-e` and the user's
5
5
  * arguments verbatim. stdio is inherited so interactive
6
- * TUI, RPC, and print modes all behave natively; exit codes and signals
7
- * propagate.
6
+ * TUI, text, JSON, and RPC modes all behave natively; exit codes and
7
+ * signals propagate.
8
8
  */
9
9
 
10
10
  import { spawn } from "node:child_process";
package/src/mcp-config.ts CHANGED
@@ -13,8 +13,10 @@
13
13
  * (read for ownership classification only — Pi reads the file itself).
14
14
  * The legacy project-root `.mcp.json` source is not read.
15
15
  *
16
- * Malformed config files fail loudly — a broken mcp.json must not silently
17
- * read as "no servers" and reject every reference.
16
+ * Malformed config files follow the discovery tier: strict mode throws
17
+ * `McpConfigError` (a broken mcp.json must not silently read as "no servers"
18
+ * under an explicit MCP policy); diagnostic mode skips the malformed source
19
+ * with a path-bearing diagnostic so valid sources still merge.
18
20
  */
19
21
 
20
22
  import { homedir } from "node:os";
@@ -34,6 +36,10 @@ export class McpConfigError extends Error {
34
36
 
35
37
  export interface McpDiscoveryOptions {
36
38
  homeDir?: string;
39
+ /** How an unreadable or malformed source is handled. "throw" (default)
40
+ * fails discovery with `McpConfigError`; "diagnose" skips the source and
41
+ * records a path-bearing diagnostic so valid sources still merge. */
42
+ invalidSource?: "throw" | "diagnose";
37
43
  }
38
44
 
39
45
  export interface MergedMcpResult {
@@ -48,6 +54,10 @@ export interface MergedMcpResult {
48
54
  /** The merged user-level configuration object, with later sources
49
55
  * overriding earlier ones per server name. */
50
56
  baseConfig?: Record<string, unknown>;
57
+ /** Path-bearing diagnostics for sources skipped in "diagnose" mode.
58
+ * Always populated by `loadMergedMcpServers`; absent from hand-built
59
+ * discovery fixtures. */
60
+ diagnostics?: string[];
51
61
  }
52
62
 
53
63
  function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
@@ -98,14 +108,19 @@ export async function loadMergedMcpServers(
98
108
  projectDir?: string,
99
109
  options?: McpDiscoveryOptions,
100
110
  ): Promise<MergedMcpResult> {
111
+ const invalidSource = options?.invalidSource ?? "throw";
101
112
  const sources = getStandardMcpConfigSources(agentDir, projectDir, options);
102
113
  const seenPaths = new Set<string>();
103
114
  const servers: Record<string, Record<string, unknown>> = {};
104
115
  const sharedServers = new Set<string>();
105
116
  const projectServers = new Set<string>();
106
117
  const serverOwners: Record<string, "user" | "project"> = {};
118
+ const diagnostics: string[] = [];
107
119
  let baseConfig: Record<string, unknown> | undefined;
108
120
 
121
+ /** Strict mode fails discovery; diagnostic mode records the path and
122
+ * skips only that source, leaving valid sources available. */
123
+
109
124
  for (const source of sources) {
110
125
  const resolvedPath = path.resolve(source.path);
111
126
  if (seenPaths.has(resolvedPath)) continue;
@@ -114,11 +129,26 @@ export async function loadMergedMcpServers(
114
129
  const result = await readJsonFile(resolvedPath);
115
130
  if (!result.ok) {
116
131
  if (result.reason === "missing") continue;
132
+ if (invalidSource === "diagnose") {
133
+ diagnostics.push(`MCP config is not valid JSON: ${resolvedPath}`);
134
+ continue;
135
+ }
117
136
  throw new McpConfigError(`MCP config is not valid JSON: ${resolvedPath}`, resolvedPath);
118
137
  }
119
138
  if (!isRecord(result.value)) {
139
+ if (invalidSource === "diagnose") {
140
+ diagnostics.push(`MCP config must be a JSON object: ${resolvedPath}`);
141
+ continue;
142
+ }
120
143
  throw new McpConfigError(`MCP config must be a JSON object: ${resolvedPath}`, resolvedPath);
121
144
  }
145
+ if (result.value.mcpServers !== undefined && !isRecord(result.value.mcpServers)) {
146
+ if (invalidSource === "diagnose") {
147
+ diagnostics.push(`"mcpServers" must be a JSON object: ${resolvedPath}`);
148
+ continue;
149
+ }
150
+ throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
151
+ }
122
152
 
123
153
  if (!source.isProject) {
124
154
  // Merge user-level base configuration: later sources override
@@ -128,9 +158,6 @@ export async function loadMergedMcpServers(
128
158
  }
129
159
 
130
160
  if (result.value.mcpServers === undefined) continue;
131
- if (!isRecord(result.value.mcpServers)) {
132
- throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
133
- }
134
161
  for (const [name, def] of Object.entries(result.value.mcpServers)) {
135
162
  // Align discovery with JSON semantics: inherited prototype keys are
136
163
  // never treated as discoverable server names.
@@ -140,11 +167,13 @@ export async function loadMergedMcpServers(
140
167
  }
141
168
  if (source.isProject === true) projectServers.add(name);
142
169
  setOwnRecordValue(serverOwners, name, source.isProject === true ? "project" : "user");
143
- const previous = Object.hasOwn(servers, name) ? servers[name] : undefined;
144
- const merged = isRecord(def) ? { ...(previous ?? {}), ...def } : { ...(previous ?? {}) };
145
- setOwnRecordValue(servers, name, merged);
170
+ // Whole-definition precedence: a later definition replaces the same-named
171
+ // server entirely. Connection, credential, and exposure fields are never
172
+ // inherited from an earlier source, so a later URL cannot pick up an
173
+ // earlier authorization header (D1).
174
+ setOwnRecordValue(servers, name, isRecord(def) ? { ...def } : {});
146
175
  }
147
176
  }
148
177
 
149
- return { servers, sharedServers, projectServers, serverOwners, baseConfig };
178
+ return { servers, sharedServers, projectServers, serverOwners, baseConfig, diagnostics };
150
179
  }
@@ -48,11 +48,27 @@ export class ActivationError extends Error {
48
48
  }
49
49
  }
50
50
 
51
- /** Pi's built-in tool names (pi 0.85.1 `allToolNames`; not exported by the
51
+ /** Pi's built-in tool names (pi 0.99.2 `allToolNames`; not exported by the
52
52
  * SDK). The integration suite guards drift. Literal tool names pass through
53
53
  * regardless — extension-provided tools are unknowable before spawn. */
54
54
  export const BUILTIN_TOOL_NAMES = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"] as const;
55
55
 
56
+ /** User-level, enabled MCP server names, sorted — the set a profile can
57
+ * actually select via `mcps` or narrow via `mcp_tools`. Project-owned and
58
+ * source-disabled servers are not usable candidates. */
59
+ function userLevelMcpCandidates(mcpDiscovery: MergedMcpResult): string[] {
60
+ return Object.keys(mcpDiscovery.servers)
61
+ .filter((s) => {
62
+ const isUser =
63
+ Object.hasOwn(mcpDiscovery.serverOwners, s) &&
64
+ mcpDiscovery.serverOwners[s] === "user" &&
65
+ !mcpDiscovery.projectServers.has(s);
66
+ const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
67
+ return isUser && isEnabled;
68
+ })
69
+ .sort();
70
+ }
71
+
56
72
  const VALID_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
57
73
 
58
74
  /** Immutable, fully resolved activation set. */
@@ -182,7 +198,12 @@ function expandReferences<T>(
182
198
  universe: readonly T[],
183
199
  nameOf: (item: T) => string,
184
200
  kind: string,
185
- options?: { literalMustExist?: boolean; onZeroMatch?: (reference: string) => void },
201
+ options?: {
202
+ literalMustExist?: boolean;
203
+ onZeroMatch?: (reference: string) => void;
204
+ /** Custom literal-miss failure (e.g. with near-miss candidates). Throws. */
205
+ literalMissError?: (reference: string) => never;
206
+ },
186
207
  ): T[] {
187
208
  const selected = new Map<string, T>();
188
209
  for (const reference of references) {
@@ -204,6 +225,10 @@ function expandReferences<T>(
204
225
  selected.set(reference, reference as T);
205
226
  continue;
206
227
  }
228
+ const missError = options?.literalMissError;
229
+ if (missError !== undefined) {
230
+ missError(reference);
231
+ }
207
232
  throw new ActivationError(`unknown ${kind}: "${reference}" does not match any discovered ${kind}`);
208
233
  }
209
234
  selected.set(reference, item);
@@ -250,6 +275,17 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
250
275
  }
251
276
  mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
252
277
  onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
278
+ literalMissError: (reference) => {
279
+ const candidates =
280
+ input.mcpDiscovery !== undefined
281
+ ? userLevelMcpCandidates(input.mcpDiscovery)
282
+ : [...(input.discoveredMcpServers ?? [])].sort();
283
+ const suffix =
284
+ candidates.length > 0
285
+ ? ` (usable candidates: ${candidates.join(", ")})`
286
+ : " (no user-level servers are discovered)";
287
+ throw new ActivationError(`unknown MCP server: "${reference}"${suffix}`);
288
+ },
253
289
  });
254
290
  if (input.mcpDiscovery !== undefined) {
255
291
  const discovery = input.mcpDiscovery;
@@ -327,17 +363,9 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
327
363
  }
328
364
 
329
365
  const mcpDiscovery = input.mcpDiscovery;
330
- const usableCandidates = Object.keys(mcpDiscovery.servers)
331
- .filter((s) => {
332
- const isUser =
333
- Object.hasOwn(mcpDiscovery.serverOwners, s) &&
334
- mcpDiscovery.serverOwners[s] === "user" &&
335
- !mcpDiscovery.projectServers.has(s);
336
- const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
337
- const isAllowedByMcps = mcps === undefined || mcps.includes(s);
338
- return isUser && isEnabled && isAllowedByMcps;
339
- })
340
- .sort();
366
+ const usableCandidates = userLevelMcpCandidates(mcpDiscovery).filter(
367
+ (s) => mcps === undefined || mcps.includes(s),
368
+ );
341
369
  const candidateMsg = usableCandidates.length > 0 ? ` (usable candidates: ${usableCandidates.join(", ")})` : "";
342
370
 
343
371
  for (const serverKey of mcpToolKeys) {
@@ -479,6 +507,14 @@ export function buildInstanceMcpConfig(
479
507
  mcpDiscovery.serverOwners[serverName] === "project" || mcpDiscovery.projectServers.has(serverName);
480
508
  if (isProjectOwned) continue;
481
509
 
510
+ // A selected server the winning source explicitly disables fails with a
511
+ // fix, never a silent enablement override (D1).
512
+ if (mcps?.includes(serverName) === true && originalDef.enabled === false) {
513
+ throw new ActivationError(
514
+ `profile "${profileName}": selected MCP server "${serverName}" is disabled in its source configuration; enable it there or remove it from "mcps"`,
515
+ );
516
+ }
517
+
482
518
  // A server explicitly selected by mcps whose definition uses a
483
519
  // transport Pi's built-in MCP extension cannot use fails activation.
484
520
  if (mcps?.includes(serverName) === true && originalDef.type === "sse") {
@@ -502,11 +538,14 @@ export function buildInstanceMcpConfig(
502
538
  setOwnRecordValue(filteredServers, serverName, def);
503
539
  }
504
540
 
505
- // Unselected user-level servers are explicitly disabled.
541
+ // Unselected user-level servers keep their complete winning definition and
542
+ // are explicitly disabled. Pi validates the transport before `enabled`, so
543
+ // a transport-less placeholder would warn on every valid disabled server.
506
544
  if (mcps !== undefined) {
507
545
  for (const userName of userServers) {
508
546
  if (mcps.includes(userName)) continue;
509
- setOwnRecordValue(filteredServers, userName, { enabled: false });
547
+ const unselectedDef = mcpDiscovery.servers[userName] ?? {};
548
+ setOwnRecordValue(filteredServers, userName, { ...unselectedDef, enabled: false });
510
549
  }
511
550
  }
512
551
 
@@ -48,7 +48,7 @@ import path from "node:path";
48
48
  import { buildInstanceMcpConfig, type ActivationPlan } from "./profile-resolver.ts";
49
49
  import { getInstancesRootDir } from "./workspace.ts";
50
50
  import { isRecord } from "./json-file.ts";
51
- import { loadMergedMcpServers } from "./mcp-config.ts";
51
+ import { loadMergedMcpServers, type MergedMcpResult } from "./mcp-config.ts";
52
52
  import type { SkillEntry } from "./skill-registry.ts";
53
53
 
54
54
  /** A configured global package and its resolved install/local root. */
@@ -83,6 +83,9 @@ export interface GeneratedRuntime {
83
83
  runtimeDir: string;
84
84
  /** Environment variables for the spawned pi process. */
85
85
  env: Record<string, string>;
86
+ /** Non-fatal diagnostics (e.g. malformed MCP sources skipped under an
87
+ * undeclared MCP policy). The launcher prints them on stderr. */
88
+ warnings: string[];
86
89
  }
87
90
 
88
91
  /** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
@@ -193,6 +196,32 @@ function userSkillExclusions(userSkills: unknown, agentDir: string, runtimeDir:
193
196
  return exclusions;
194
197
  }
195
198
 
199
+ /** Pi's built-in MCP discovery entry points (see installed Pi
200
+ * `dist/extensions/mcp/index.js`, `codemode/tool.js`, `tool-search/tool.js`).
201
+ * These are the tools a narrowed `tools` profile must keep reachable when an
202
+ * MCP server is enabled; their identities are Pi's, not a profile field. */
203
+ const MCP_GATEWAY_TOOL_NAMES = ["codemode", "tool_search"] as const;
204
+
205
+ /** Whether the prepared effective MCP set contains at least one enabled
206
+ * server (user-level snapshot entries with `enabled !== false`, plus any
207
+ * enabled trusted-project server Pi reads itself). */
208
+ function hasEnabledMcpServer(
209
+ instanceMcpConfig: Record<string, unknown>,
210
+ discovery: MergedMcpResult,
211
+ ): boolean {
212
+ const snapshotServers = instanceMcpConfig.mcpServers;
213
+ if (isRecord(snapshotServers)) {
214
+ for (const def of Object.values(snapshotServers)) {
215
+ if (isRecord(def) && def.enabled !== false) return true;
216
+ }
217
+ }
218
+ for (const name of discovery.projectServers) {
219
+ const def = discovery.servers[name];
220
+ if (def !== undefined && def.enabled !== false) return true;
221
+ }
222
+ return false;
223
+ }
224
+
196
225
  function buildSelectionSettings(
197
226
  plan: ActivationPlan,
198
227
  userSettings: Record<string, unknown>,
@@ -405,14 +434,47 @@ export interface ResolvedNames {
405
434
  /** Writes settings.json + pi-profile.json into an existing runtime dir and
406
435
  * keeps the trust.json link in place for every profile: Pi reads its
407
436
  * project-scope decision from that path, and project-level resources belong
408
- * to Pi's trust gate rather than to the profile. */
437
+ * to Pi's trust gate rather than to the profile.
438
+ *
439
+ * All generated content (settings, launch plan, MCP snapshot, diagnostics)
440
+ * is prepared in memory before any runtime file is touched, so a discovery
441
+ * failure can never leave a half-written runtime dir (D3). */
409
442
  export async function writeRuntimeFiles(
410
443
  runtimeDir: string,
411
444
  plan: ActivationPlan,
412
445
  options: RuntimeFileOptions,
413
- ): Promise<void> {
446
+ ): Promise<{ warnings: string[] }> {
447
+ const warnings: string[] = [];
414
448
  const settings = await computeSettings(plan, options, runtimeDir);
415
- await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
449
+
450
+ // Prepare the MCP snapshot and diagnostics before the write stage. A
451
+ // declared mcps or nonempty mcp_tools policy is strict; an undeclared
452
+ // policy diagnoses malformed sources by path and keeps valid ones (D2).
453
+ const hasMcpPolicy =
454
+ plan.mcps !== undefined || (plan.mcpTools !== undefined && Object.keys(plan.mcpTools).length > 0);
455
+ const discovery = await loadMergedMcpServers(
456
+ options.agentDir,
457
+ options.projectDir,
458
+ {
459
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
460
+ invalidSource: hasMcpPolicy ? "throw" : "diagnose",
461
+ },
462
+ );
463
+ warnings.push(...(discovery.diagnostics ?? []));
464
+ const instanceMcpConfig = buildInstanceMcpConfig(plan.profile, discovery, plan.mcps, plan.mcpTools);
465
+
466
+ // When a profile narrows `tools` and the effective MCP set still has an
467
+ // enabled server, keep Pi's native MCP discovery entry points reachable
468
+ // (D4). The marker drives the session-start preservation; the
469
+ // defaultTools baseline covers the boot window before session_start.
470
+ const mcpGateways = plan.tools !== undefined && hasEnabledMcpServer(instanceMcpConfig, discovery);
471
+ if (mcpGateways) {
472
+ const current = Array.isArray(settings.defaultTools) ? (settings.defaultTools as string[]) : [];
473
+ settings.defaultTools = [
474
+ ...current,
475
+ ...MCP_GATEWAY_TOOL_NAMES.filter((name) => !current.includes(name)),
476
+ ];
477
+ }
416
478
 
417
479
  // The launch plan feeds the in-pi extension: tool re-application after
418
480
  // reload (the tools strict allowlist), in-session switching, status
@@ -420,33 +482,31 @@ export async function writeRuntimeFiles(
420
482
  // agentDir is the REAL agent dir — the extension needs it for trust
421
483
  // checks, state files, and catalog reads (its own
422
484
  // PI_CODING_AGENT_DIR points at this runtime dir).
423
- await writeFile(
424
- path.join(runtimeDir, "pi-profile.json"),
425
- `${JSON.stringify(
426
- {
427
- profile: plan.profile,
428
- source: plan.source,
429
- agentDir: options.agentDir,
430
- ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
431
- ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
432
- ...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
433
- ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
434
- ...(plan.mcpTools !== undefined ? { mcpTools: plan.mcpTools } : {}),
435
- // The resolved sets feed /profile status (absolute paths) and the
436
- // glob-delta diff against the previous activation.
437
- resolved: {
438
- skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
439
- extensions: plan.extensions,
440
- },
441
- // Zero-match glob references (ADR-0009) — surfaced by /profile status
442
- // so a typo'd glob is visible instead of silently selecting nothing.
443
- ...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
444
- ...options.planExtras,
445
- },
446
- null,
447
- 2,
448
- )}\n`,
449
- );
485
+ const launchPlan = {
486
+ profile: plan.profile,
487
+ source: plan.source,
488
+ agentDir: options.agentDir,
489
+ ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
490
+ ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
491
+ ...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
492
+ ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
493
+ ...(plan.mcpTools !== undefined ? { mcpTools: plan.mcpTools } : {}),
494
+ ...(mcpGateways ? { mcpGateways: true } : {}),
495
+ // The resolved sets feed /profile status (absolute paths) and the
496
+ // glob-delta diff against the previous activation.
497
+ resolved: {
498
+ skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
499
+ extensions: plan.extensions,
500
+ },
501
+ // Zero-match glob references (ADR-0009) — surfaced by /profile status
502
+ // so a typo'd glob is visible instead of silently selecting nothing.
503
+ ...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
504
+ ...options.planExtras,
505
+ };
506
+
507
+ // --- write stage: all content is ready; no reads re-run here. ---
508
+ await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
509
+ await writeFile(path.join(runtimeDir, "pi-profile.json"), `${JSON.stringify(launchPlan, null, 2)}\n`);
450
510
 
451
511
  // Every profile gets the link, dangling allowed: Pi's stored trust decision
452
512
  // is what makes a trusted project's resources visible, and a decision Pi
@@ -460,20 +520,11 @@ export async function writeRuntimeFiles(
460
520
 
461
521
  // MCP Servers generation: the instance mcp.json is always a generated
462
522
  // snapshot of the merged user-level configuration; it is never a symlink
463
- // or a copy of the real agentDir file (ADR-0016).
523
+ // or a copy of the real agentDir file (ADR-0016). The replacement content
524
+ // was prepared above, so the old file is removed only once its successor
525
+ // is ready to write.
464
526
  const mcpInstancePath = path.join(runtimeDir, "mcp.json");
465
527
  try { await rm(mcpInstancePath); } catch {}
466
- const discovery = await loadMergedMcpServers(
467
- options.agentDir,
468
- options.projectDir,
469
- options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
470
- );
471
- const instanceMcpConfig = buildInstanceMcpConfig(
472
- plan.profile,
473
- discovery,
474
- plan.mcps,
475
- plan.mcpTools,
476
- );
477
528
  await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
478
529
 
479
530
  // Instructions generation (Ticket 04)
@@ -486,6 +537,8 @@ export async function writeRuntimeFiles(
486
537
 
487
538
  // Full-fidelity symlink mirroring and dangling link cleanup (Ticket 02).
488
539
  await syncAgentSymlinks(options.agentDir, runtimeDir);
540
+
541
+ return { warnings };
489
542
  }
490
543
 
491
544
  /**
@@ -595,12 +648,13 @@ export async function generateRuntimeDir(
595
648
  // their switches) rewrite each other's files (ADR-0010).
596
649
  const runtimeDir = await mkdtemp(path.join(runtimeRoot, "launch-"));
597
650
 
598
- await writeRuntimeFiles(runtimeDir, plan, options);
651
+ const { warnings } = await writeRuntimeFiles(runtimeDir, plan, options);
599
652
 
600
653
  return {
601
654
  runtimeDir,
602
655
  env: {
603
656
  PI_CODING_AGENT_DIR: runtimeDir,
604
657
  },
658
+ warnings,
605
659
  };
606
660
  }
@@ -49,6 +49,11 @@ export interface LaunchPlanFile {
49
49
  disabledTools?: string[];
50
50
  mcps?: string[];
51
51
  mcpTools?: Record<string, string[]>;
52
+ /** Marks a narrowed `tools` profile whose effective MCP set still has an
53
+ * enabled server; session start must retain Pi's native MCP discovery
54
+ * entry points (codemode / tool_search) if they were natively
55
+ * registered (D4). */
56
+ mcpGateways?: boolean;
52
57
  switchedFrom?: string;
53
58
  persistSelection?: boolean;
54
59
  clearOverlay?: boolean;
@@ -99,6 +104,14 @@ export function isMcpOwnedTool(tool: {
99
104
  return info.path === "builtin:mcp";
100
105
  }
101
106
 
107
+ /** Pi's native MCP discovery entry points and their built-in source paths
108
+ * (see installed Pi `dist/extensions/index.js`). A same-named tool from any
109
+ * other source is never treated as an MCP gateway. */
110
+ const MCP_GATEWAYS = [
111
+ { name: "codemode", sourcePath: "builtin:codemode" },
112
+ { name: "tool_search", sourcePath: "builtin:tool-search" },
113
+ ] as const;
114
+
102
115
  /** Applies the plan carried by the runtime dir's pi-profile.json. */
103
116
  export async function applyLaunchPlan(input: {
104
117
  runtimeDir: string;
@@ -116,7 +129,7 @@ export async function applyLaunchPlan(input: {
116
129
 
117
130
  // --- tools ---
118
131
  const hasOverlayDisables = plan.disabledTools !== undefined && plan.disabledTools.length > 0;
119
- if (plan.toolReferences !== undefined || hasOverlayDisables) {
132
+ if (plan.toolReferences !== undefined || hasOverlayDisables || plan.mcpGateways === true) {
120
133
  const allTools = surface.getAllTools();
121
134
  const mcpToolNames: string[] = [];
122
135
  const nonMcpToolNames: string[] = [];
@@ -155,6 +168,7 @@ export async function applyLaunchPlan(input: {
155
168
  active = allTools.map((tool) => tool.name);
156
169
  }
157
170
 
171
+ const disabledSet = new Set<string>();
158
172
  if (hasOverlayDisables) {
159
173
  const allLiveNames = allTools.map((tool) => tool.name);
160
174
  const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
@@ -167,9 +181,33 @@ export async function applyLaunchPlan(input: {
167
181
  `profile "${plan.profile}": overlay tool entries ${vanishedEntries.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
168
182
  );
169
183
  }
170
- const disabledSet = new Set(disabled);
184
+ for (const name of disabled) disabledSet.add(name);
171
185
  active = active.filter((name) => !disabledSet.has(name));
172
186
  }
187
+
188
+ // Native MCP discovery entry points stay reachable for narrowed `tools`
189
+ // profiles when the plan marked them (D4). Only the built-in
190
+ // registration counts; an explicit overlay disable still wins.
191
+ if (plan.mcpGateways === true) {
192
+ for (const gateway of MCP_GATEWAYS) {
193
+ if (disabledSet.has(gateway.name)) continue;
194
+ const registered = allTools.find((tool) => tool.name === gateway.name);
195
+ if (registered === undefined) {
196
+ warnings.push(
197
+ `profile "${plan.profile}": MCP entry point ${JSON.stringify(gateway.name)} is unavailable because its built-in extension did not register it`,
198
+ );
199
+ continue;
200
+ }
201
+ if (registered.sourceInfo?.path === gateway.sourcePath) {
202
+ if (!active.includes(gateway.name)) active.push(gateway.name);
203
+ } else {
204
+ warnings.push(
205
+ `profile "${plan.profile}": MCP entry point ${JSON.stringify(gateway.name)} is registered by ${registered.sourceInfo?.path ?? "an unknown source"}, not the built-in extension; it was not used as an MCP gateway`,
206
+ );
207
+ }
208
+ }
209
+ }
210
+
173
211
  surface.setActiveTools(active);
174
212
  }
175
213
 
@@ -204,22 +204,7 @@ export async function switchProfile(
204
204
  ...(previousPlan.mcps !== undefined ? { mcps: previousPlan.mcps } : {}),
205
205
  }
206
206
  : undefined;
207
- await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
208
- agentDir: deps.realAgentDir,
209
- projectDir: resolved.projectDir,
210
- discovery: resolved.discovery,
211
- planExtras: {
212
- ...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
213
- // `/profile use` persists; `/profile reload` keeps the current
214
- // profile's existing persistence (launch selections stay transient).
215
- persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
216
- // A switch discards the previous profile's overlay; the post-reload
217
- // instance drops it from the state file. Customize/reset manage the
218
- // overlay directly and never set this.
219
- ...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
220
- ...(previousResolved !== undefined ? { previousResolved } : {}),
221
- },
222
- });
207
+ const warnings = [...resolved.warnings];
223
208
 
224
209
  const rollback = async (cause: string): Promise<never> => {
225
210
  // Restore the verified snapshot and reload again — the runtime must
@@ -236,7 +221,26 @@ export async function switchProfile(
236
221
  );
237
222
  };
238
223
 
224
+ // The write-and-reload interval is one rollback boundary: a failure in any
225
+ // write after the first, or in reload, restores every managed file (D3).
239
226
  try {
227
+ const written = await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
228
+ agentDir: deps.realAgentDir,
229
+ projectDir: resolved.projectDir,
230
+ discovery: resolved.discovery,
231
+ planExtras: {
232
+ ...(isSwitch && current.profile !== undefined ? { switchedFrom: current.profile } : {}),
233
+ // `/profile use` persists; `/profile reload` keeps the current
234
+ // profile's existing persistence (launch selections stay transient).
235
+ persistSelection: options?.reloadCurrent === true ? current.persistSelection : true,
236
+ // A switch discards the previous profile's overlay; the post-reload
237
+ // instance drops it from the state file. Customize/reset manage the
238
+ // overlay directly and never set this.
239
+ ...(options?.clearOverlay === true ? { clearOverlay: true } : {}),
240
+ ...(previousResolved !== undefined ? { previousResolved } : {}),
241
+ },
242
+ });
243
+ warnings.push(...written.warnings);
240
244
  await deps.reload();
241
245
  } catch (error) {
242
246
  await rollback(error instanceof Error ? error.message : String(error));
@@ -257,5 +261,5 @@ export async function switchProfile(
257
261
  }
258
262
  }
259
263
 
260
- return { profile: resolved.plan.profile, warnings: resolved.warnings };
264
+ return { profile: resolved.plan.profile, warnings };
261
265
  }