pi-profile-switch 0.12.1 → 0.13.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
@@ -51,7 +51,7 @@ pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`*
51
51
  }
52
52
  ```
53
53
 
54
- One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), and your internal skills; wires up two MCP servers; allows the built-in tools; restricts GitHub MCP tools while denying Linear tools; and pins the model and standing instructions:
54
+ One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill and your internal skills; wires up two MCP servers; allows the built-in tools; restricts GitHub MCP tools while denying Linear tools; and pins the model and standing instructions:
55
55
 
56
56
  ```json
57
57
  {
@@ -59,11 +59,7 @@ One profile can use every field at once. This example `impl` profile (`impl.json
59
59
  "description": "Full-powered implementation profile: every available field, pinned model",
60
60
  "skills": [
61
61
  "tdd",
62
- "internal-*",
63
- "mcp-scripting"
64
- ],
65
- "extensions": [
66
- "pi-mcp-adapter"
62
+ "internal-*"
67
63
  ],
68
64
  "mcps": [
69
65
  "github",
@@ -95,11 +91,17 @@ One profile can use every field at once. This example `impl` profile (`impl.json
95
91
  How fields resolve:
96
92
 
97
93
  - `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.
98
- - `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-provided tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`.
99
- - `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal pi-mcp-adapter selectors (original or prefixed names; either form can select the same tool). 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 adapter/server before writing them.
100
- - A nonempty `mcp_tools` requires `pi-mcp-adapter` even when `mcps` is omitted. Existing adapter `includeTools` filters are combined only when the profile uses identical selectors or the existing filter is `"*"`; otherwise activation fails before writing runtime files. Existing `excludeTools` restrictions continue to apply.
101
- - **Migration note:** Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
102
- - `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
94
+ - `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`.
95
+ - `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.
96
+ - **Migration notes:**
97
+ - Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
98
+ - 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.
99
+ - `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.
100
+ - **Omitting `mcps`** leaves all discovered user-level servers at their normal availability.
101
+ - **`mcps: []`** disables every discovered user-level server, including agentDir-only servers; every unselected user-level server is explicitly marked `enabled: false` in the generated instance `mcp.json`. Project-level servers are never narrowed.
102
+ - Trusted project-level MCP servers are always kept enabled and are never narrowed by `mcps`.
103
+ - Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
104
+ - 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.
103
105
  - Any field you omit keeps plain Pi behavior.
104
106
 
105
107
  The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
package/README.zh-CN.md CHANGED
@@ -100,6 +100,10 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
100
100
  - 非空的 `mcp_tools` 即使在省略 `mcps` 时也需要 `pi-mcp-adapter`。只有当 profile selector 与已有的 `includeTools` 字面值完全相同,或已有列表是 `"*"` 时才能安全组合;否则会在写入 runtime 文件前报错。已有的 `excludeTools` 限制仍然生效。
101
101
  - **迁移提示**:旧 profile 中写入 `tools` 的 MCP 工具名称或 glob(如 `mcp__*`、`<server>_*`)不再控制 MCP 访问;如有需要请迁移至 `mcp_tools`。
102
102
  - `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
103
+ - **省略 `mcps`**:保留所有已发现用户级 server 的原生可用性。
104
+ - **`mcps: []` 且选中了 `pi-mcp-adapter`**:禁用所有已发现用户级 server(共享位置的 server 会被显式标记为 `disabled`;仅位于 agentDir 的 server 会从生成的实例 `mcp.json` 中省略)。
105
+ - **`mcps: []` 但未选中 `pi-mcp-adapter`**:该声明不生效,不会读取 MCP 配置、改变 server 可用性,也不会因配置文件损坏而报错。
106
+ - 受信任的项目级 MCP server 始终保持启用,不受 `mcps` 收窄。
103
107
  - 未写的字段保持原生 Pi 行为。
104
108
 
105
109
  [`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
package/bin/pi-profile.ts CHANGED
@@ -19,7 +19,7 @@ import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/init
19
19
  import { untrustedProjectDiagnostic } from "../src/launcher/untrusted-project-diagnostic.ts";
20
20
  import { sweepStaleInstances } from "../src/launcher/runtime-cleanup.ts";
21
21
  import { spawnPi } from "../src/launcher/spawn.ts";
22
- import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
22
+ import { McpConfigError } from "../src/mcp-config.ts";
23
23
  import { CatalogError } from "../src/profile-catalog.ts";
24
24
  import { ActivationError } from "../src/profile-resolver.ts";
25
25
  import { ensureStarterAssets } from "../src/starter-assets.ts";
@@ -77,13 +77,13 @@ try {
77
77
  });
78
78
  } catch (error) {
79
79
  // Launcher input/selection failures (unknown profile, unresolvable
80
- // references, missing adapter, malformed catalogs) are usage errors:
80
+ // references, malformed catalogs, illegal MCP config) are usage errors:
81
81
  // exit 2. Unexpected failures: exit 1.
82
82
  const isUsageError =
83
83
  error instanceof UnknownProfileError ||
84
84
  error instanceof ActivationError ||
85
85
  error instanceof ExtensionError ||
86
- error instanceof MissingMcpAdapterError ||
86
+
87
87
  error instanceof McpConfigError ||
88
88
  error instanceof CatalogError;
89
89
  console.error(error instanceof Error ? `pi-profile: ${error.message}` : error);
@@ -3,11 +3,7 @@
3
3
  "description": "Full-powered implementation profile: every available field, pinned model",
4
4
  "skills": [
5
5
  "tdd",
6
- "internal-*",
7
- "mcp-scripting"
8
- ],
9
- "extensions": [
10
- "pi-mcp-adapter"
6
+ "internal-*"
11
7
  ],
12
8
  "mcps": [
13
9
  "github",
@@ -24,7 +20,7 @@
24
20
  ],
25
21
  "mcp_tools": {
26
22
  "github": [
27
- "github_search",
23
+ "search",
28
24
  "get_issue"
29
25
  ],
30
26
  "linear": []
@@ -227,13 +227,14 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
227
227
  );
228
228
  const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
229
229
  const disabledMcpServers = discoveredMcpServers.filter(
230
- (server) => mcpDiscovery.servers[server]?.disabled === true,
230
+ (server) => mcpDiscovery.servers[server]?.enabled === false,
231
231
  );
232
232
  const report = buildStatusReport({
233
233
  plan,
234
234
  overlay: state.overlay,
235
235
  discoveredMcpServers,
236
236
  disabledMcpServers,
237
+ projectMcpServers: [...mcpDiscovery.projectServers].sort(),
237
238
  commands: pi.getCommands(),
238
239
  tools: pi.getAllTools(),
239
240
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.12.1",
3
+ "version": "0.13.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": [
@@ -36,7 +36,6 @@
36
36
  "@earendil-works/pi-coding-agent": "*",
37
37
  "@types/node": "^24.0.0",
38
38
  "ajv": "^8.20.0",
39
- "pi-mcp-adapter": "^2.38.0",
40
39
  "typescript": "^5.8.0",
41
40
  "vitest": "^3.0.0"
42
41
  },
@@ -54,9 +54,9 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
54
54
  | `description` | `string` | Short description of the profile (e.g. `"Read-only review profile"`). |
55
55
  | `skills` | `string[]` | Skill names or globs to reference. When undeclared, available skills are not narrowed. |
56
56
  | `extensions` | `string[]` | Extension identifiers or globs to reference. When undeclared, available extensions are not narrowed. |
57
- | `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, `mcps` itself adds no adapter requirement; a nonempty `mcp_tools` still requires `pi-mcp-adapter`. |
57
+ | `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, all discovered user-level servers keep their normal availability. An explicit `mcps: []` disables every discovered user-level server. Project-level servers are never narrowed. |
58
58
  | `tools` | `string[]` | Whitelisted non-MCP tool names or globs (built-in and extension tools only). Live MCP tools remain usable independently of `tools`. When undeclared, tools are not narrowed and Pi's native tool set is kept. |
59
- | `mcp_tools` | `Record<string, string[]>` | Per-server MCP tool selection: literal server names mapped to literal adapter selectors (original or prefixed names; both forms may select the same tool). Globs are rejected. An omitted server allows all tools; a nonempty array allows only matches; an empty array (`[]`) denies all tools while keeping the server enabled. An empty object (`{}`) behaves like omission. Nonempty declarations require `pi-mcp-adapter`, even when `mcps` is omitted. |
59
+ | `mcp_tools` | `Record<string, string[]>` | Per-server MCP tool selection: literal server names mapped to literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are rejected. An omitted server allows all tools; a nonempty array allows only matches; an empty array (`[]`) denies all tools while keeping the server enabled. An empty object (`{}`) behaves like omission. |
60
60
  | `defaultProvider` | `string` | Default model provider (e.g. `"anthropic"`, `"openai"`). Effective only when declared together with `defaultModel`. |
61
61
  | `defaultModel` | `string` | Default model name (e.g. `"claude-sonnet-4-5"`). Effective only when declared together with `defaultProvider`. |
62
62
  | `defaultThinkingLevel` | `string` | Default thinking level; allowed values: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. Effective only when the model declaration holds. |
@@ -70,7 +70,6 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
70
70
  "skills": [
71
71
  "profile-config"
72
72
  ],
73
- "extensions": [],
74
73
  "mcps": [],
75
74
  "tools": [
76
75
  "read",
@@ -104,17 +103,17 @@ When helping the user configure a profile, check or consult the following locati
104
103
  - Absolute paths or `~/` paths.
105
104
  - Glob matching.
106
105
  3. **MCP servers**:
107
- - Discovery locations: the standard configuration locations recognized by `pi-mcp-adapter` — on the global side `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, `<agentDir>/mcp.json`; trusted projects additionally have `<projectDir>/.mcp.json` and `<projectDir>/.pi/mcp.json`.
106
+ - Discovery locations: Pi's user-level MCP configuration files — on the global side `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, `<agentDir>/mcp.json`; trusted projects additionally have `<projectDir>/.pi/mcp.json`.
108
107
  - Reference identity: the server key names under the `mcpServers` object in those configuration files.
109
- - A profile declaring nonempty `mcps` or nonempty `mcp_tools` must ensure the `pi-mcp-adapter` extension is available; omitting `mcps` does not remove the requirement for nonempty `mcp_tools`.
108
+ - Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
110
109
  4. **Tools**:
111
110
  - Reference identity: non-MCP tool names in Pi's live tool registry.
112
111
  - Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.) and extension-contributed tools.
113
112
  - MCP-owned tools are **not** controlled by `tools`; legacy references such as `"mcp__*"` or `"github_*"` in `tools` must be migrated to `mcp_tools`.
114
113
  5. **MCP Tools (`mcp_tools`)**:
115
- - Reference identity: keys are literal configured MCP server names; values are literal pi-mcp-adapter selectors. Original and prefixed selectors can both match the same tool; globs are not accepted.
116
- - Selectors are not checked against a live tool catalog. A selector matching no tool stays restrictive and produces no name diagnostic; verify selector forms with the adapter or MCP server before writing the profile.
117
- - If the server already has nonempty `includeTools`, each requested selector must be identical to an existing literal or the existing list must include `"*"`; otherwise activation fails before runtime files are written. Existing `excludeTools` still applies.
114
+ - Reference identity: keys are literal configured MCP server names; values are literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are not accepted.
115
+ - Selectors are not checked against a live tool catalog. A selector matching no tool stays restrictive and produces no name diagnostic; verify selector forms with the MCP server before writing the profile.
116
+ - Prefixed selectors from previous MCP integrations (e.g. `<server>_<tool>`) no longer apply; replace them with the literal tool names from Pi's built-in MCP extension.
118
117
  - Omitting a server key preserves unrestricted access to that server's tools.
119
118
  - An empty list (`[]`) denies all tools for that server while keeping the server enabled.
120
119
  - A nonempty list (e.g. `["search", "get_issue"]`) allows only those tools across direct tools, gateway proxies, and scripts.
@@ -35,7 +35,7 @@ export interface DiscoveredPackage {
35
35
  * npm:/git:/github: prefix (and any version spec) when unreadable. */
36
36
  name: string;
37
37
  /** The source string exactly as configured in the user's settings
38
- * (e.g. "npm:pi-mcp-adapter"); accepted as a reference alias. */
38
+ * (e.g. "npm:pi-web-access"); accepted as a reference alias. */
39
39
  source: string;
40
40
  /** Absolute install/local root of the package. */
41
41
  root: string;
@@ -14,7 +14,7 @@
14
14
  import path from "node:path";
15
15
 
16
16
  import { isRecord, readJsonFile } from "../json-file.ts";
17
- import { isAdapterExtension, loadMergedMcpServers, MissingMcpAdapterError } from "../mcp-config.ts";
17
+ import { loadMergedMcpServers } from "../mcp-config.ts";
18
18
  import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
19
19
  import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
20
20
  import { resolveProjectTrust } from "../project-trust.ts";
@@ -125,7 +125,7 @@ export async function resolveInitialProfile(
125
125
  // The default profile is normally unfiltered. With an overlay it becomes
126
126
  // a synthetic "everything minus disabled" selection (PRD: overlays may
127
127
  // temporarily narrow default's scope). MCP narrowing on default is
128
- // rejected — the adapter's own /mcp commands own that surface natively.
128
+ // rejected — the default profile declares no MCP server selection.
129
129
  const overlay = options?.overlay;
130
130
  const narrowed =
131
131
  overlay !== undefined &&
@@ -138,7 +138,7 @@ export async function resolveInitialProfile(
138
138
  }
139
139
  if ((overlay.disabledMcps?.length ?? 0) > 0) {
140
140
  throw new ActivationError(
141
- "the default profile has no MCP allowlist to narrow — use the adapter's own /mcp commands instead",
141
+ "the default profile has no MCP allowlist to narrow",
142
142
  );
143
143
  }
144
144
  const synthetic: ResolvedProfile = {
@@ -158,10 +158,13 @@ export async function resolveInitialProfile(
158
158
  return { plan, discovery, projectDir, projectTrusted, warnings };
159
159
  }
160
160
 
161
+ const discovery = await discoverLauncherResources({ ...context, projectTrusted });
162
+
161
163
  const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
162
164
  const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
165
+ const declaredMcps = profile.definition.mcps;
163
166
  const needsMcp = Boolean(
164
- profile.definition.mcps?.length ||
167
+ declaredMcps !== undefined ||
165
168
  hasMcpTools ||
166
169
  (options?.overlay?.disabledMcps?.length ?? 0) > 0,
167
170
  );
@@ -169,7 +172,6 @@ export async function resolveInitialProfile(
169
172
  ? await loadMergedMcpServers(context.agentDir, projectDir)
170
173
  : undefined;
171
174
 
172
- const discovery = await discoverLauncherResources({ ...context, projectTrusted });
173
175
  const plan = await resolveProfile({
174
176
  profile,
175
177
  skills: discovery.skills,
@@ -181,15 +183,5 @@ export async function resolveInitialProfile(
181
183
  liveToolNames: options?.liveToolNames,
182
184
  });
183
185
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
184
- if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
185
- throw new MissingMcpAdapterError(plan.profile, "mcps");
186
- }
187
- if (
188
- plan.mcpTools !== undefined &&
189
- Object.keys(plan.mcpTools).length > 0 &&
190
- !plan.extensions.some(isAdapterExtension)
191
- ) {
192
- throw new MissingMcpAdapterError(plan.profile, "mcp_tools");
193
- }
194
186
  return { plan, discovery, projectDir, projectTrusted, warnings };
195
187
  }
package/src/mcp-config.ts CHANGED
@@ -1,18 +1,17 @@
1
1
  /**
2
- * AdapterConfigDiscovery: reads MCP server names and configurations
3
- * pi-mcp-adapter would discover from its standard configuration files,
4
- * without ever managing them.
2
+ * McpConfigDiscovery: reads MCP server names and configurations from the
3
+ * user-level sources Pi's built-in MCP extension reads, without ever
4
+ * managing them.
5
5
  *
6
6
  * pi-profile never stores MCP connection parameters or credentials
7
- * (ADR-0002); this module reads config definitions to validate references
8
- * before spawn and filter instance mcp.json files.
7
+ * (ADR-0016); this module reads config definitions to validate references
8
+ * before spawn and to build the instance `mcp.json` snapshot.
9
9
  *
10
10
  * Discovery scope: standard user-global configs (~/.config/mcp/mcp.json,
11
11
  * ~/.agents/mcp.json, ~/.agents/mcp/mcp.json), the Pi-global
12
- * `<agentDir>/mcp.json`, and, when trusted, the project's `.mcp.json` and
13
- * `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
14
- * legacy locations (~/.claude/mcp.json et al.) are invisible here unless
15
- * imported.
12
+ * `<agentDir>/mcp.json`, and, when trusted, the project's `.pi/mcp.json`
13
+ * (read for ownership classification only — Pi reads the file itself).
14
+ * The legacy project-root `.mcp.json` source is not read.
16
15
  *
17
16
  * Malformed config files fail loudly — a broken mcp.json must not silently
18
17
  * read as "no servers" and reject every reference.
@@ -40,12 +39,14 @@ export interface McpDiscoveryOptions {
40
39
  export interface MergedMcpResult {
41
40
  servers: Record<string, Record<string, unknown>>;
42
41
  sharedServers: Set<string>;
43
- /** Servers defined in a trusted project's own config (`.mcp.json`,
44
- * `.pi/mcp.json`). They are not the profile's to narrow. */
42
+ /** Servers defined in a trusted project's own `.pi/mcp.json`. They are
43
+ * not the profile's to narrow. */
45
44
  projectServers: Set<string>;
46
45
  /** Winning origin of each discovered server: "project" if defined or
47
46
  * shadowed by project-level configuration, "user" otherwise. */
48
47
  serverOwners: Record<string, "user" | "project">;
48
+ /** The merged user-level configuration object, with later sources
49
+ * overriding earlier ones per server name. */
49
50
  baseConfig?: Record<string, unknown>;
50
51
  }
51
52
 
@@ -63,13 +64,16 @@ export interface McpConfigSource {
63
64
  }
64
65
 
65
66
  /**
66
- * Standard MCP configuration sources recognized by pi-mcp-adapter in precedence order:
67
+ * Standard MCP configuration sources for Pi's built-in MCP extension, in
68
+ * precedence order. Project scope is classification-only: the trusted
69
+ * project's `.pi/mcp.json` is read so its servers can be reported as
70
+ * project-owned, but Pi reads that file itself.
71
+ *
67
72
  * 1. ~/.config/mcp/mcp.json (user-global standard MCP)
68
73
  * 2. ~/.agents/mcp.json (user-global .agents MCP)
69
74
  * 3. ~/.agents/mcp/mcp.json (user-global .agents nested MCP)
70
75
  * 4. <agentDir>/mcp.json (Pi global override)
71
- * 5. <projectDir>/.mcp.json (project standard MCP, when project is trusted)
72
- * 6. <projectDir>/.pi/mcp.json (project Pi override, when project is trusted)
76
+ * 5. <projectDir>/.pi/mcp.json (project Pi override, when project is trusted)
73
77
  */
74
78
  export function getStandardMcpConfigSources(
75
79
  agentDir: string,
@@ -84,7 +88,6 @@ export function getStandardMcpConfigSources(
84
88
  { path: path.join(agentDir, "mcp.json"), isShared: false, isAgentDir: true },
85
89
  ];
86
90
  if (projectDir !== undefined) {
87
- sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true, isProject: true });
88
91
  sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false, isProject: true });
89
92
  }
90
93
  return sources;
@@ -116,17 +119,21 @@ export async function loadMergedMcpServers(
116
119
  if (!isRecord(result.value)) {
117
120
  throw new McpConfigError(`MCP config must be a JSON object: ${resolvedPath}`, resolvedPath);
118
121
  }
119
- if (source.isAgentDir) {
120
- baseConfig = result.value;
122
+
123
+ if (!source.isProject) {
124
+ // Merge user-level base configuration: later sources override
125
+ // earlier ones, with the agentDir file as the final user-level
126
+ // source. Project sources do not participate in the snapshot.
127
+ baseConfig = baseConfig === undefined ? { ...result.value } : { ...baseConfig, ...result.value };
121
128
  }
129
+
122
130
  if (result.value.mcpServers === undefined) continue;
123
131
  if (!isRecord(result.value.mcpServers)) {
124
132
  throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
125
133
  }
126
134
  for (const [name, def] of Object.entries(result.value.mcpServers)) {
127
- // The real adapter copies server entries into an ordinary {} and
128
- // cannot represent "__proto__" as a discoverable server name; align
129
- // discovery so inherited prototype keys are never treated as servers.
135
+ // Align discovery with JSON semantics: inherited prototype keys are
136
+ // never treated as discoverable server names.
130
137
  if (name === "__proto__") continue;
131
138
  if (source.isShared) {
132
139
  sharedServers.add(name);
@@ -141,37 +148,3 @@ export async function loadMergedMcpServers(
141
148
 
142
149
  return { servers, sharedServers, projectServers, serverOwners, baseConfig };
143
150
  }
144
-
145
- /** Server names the adapter would discover: standard global MCP configs,
146
- * global agentDir config, plus the trusted project's configs. Pass `projectDir`
147
- * only when the trust check passed — an untrusted project's config is never read. */
148
- export async function discoverAdapterServerNames(
149
- agentDir: string,
150
- projectDir?: string,
151
- options?: McpDiscoveryOptions,
152
- ): Promise<string[]> {
153
- const { servers } = await loadMergedMcpServers(agentDir, projectDir, options);
154
- return Object.keys(servers).sort();
155
- }
156
-
157
- export class MissingMcpAdapterError extends Error {
158
- constructor(profile: string, reason: "mcps" | "mcp_tools" = "mcps") {
159
- super(
160
- reason === "mcp_tools"
161
- ? `profile "${profile}" declares MCP tools but pi-mcp-adapter is not active. ` +
162
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`
163
- : `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
164
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
165
- );
166
- this.name = "MissingMcpAdapterError";
167
- }
168
- }
169
-
170
- /** Identifies the adapter among active extension entries by its install
171
- * path containing "pi-mcp-adapter" (npm package roots and local dirs both
172
- * match). Heuristic by design — activation-plan entries carry no package
173
- * source string. */
174
- export function isAdapterExtension(entry: { entry: string }): boolean {
175
- return entry.entry.includes("pi-mcp-adapter");
176
- }
177
-
@@ -44,7 +44,7 @@ export interface ProfileDefinition {
44
44
  extensions?: string[];
45
45
  mcps?: string[];
46
46
  tools?: string[];
47
- /** Literal adapter selectors (original or prefixed names); globs are rejected. */
47
+ /** Literal MCP tool names per server; globs are rejected. */
48
48
  mcp_tools?: Record<string, string[]>;
49
49
  defaultProvider?: string;
50
50
  defaultModel?: string;
@@ -125,7 +125,7 @@ function readMcpTools(
125
125
  for (const tool of tools) {
126
126
  if (isGlobPattern(tool)) {
127
127
  throw new CatalogError(
128
- `${prefix}profile "${profileName}": "mcp_tools" entry "${tool}" in server "${server}" is a glob pattern; literal adapter tool selectors are required`,
128
+ `${prefix}profile "${profileName}": "mcp_tools" entry "${tool}" in server "${server}" is a glob pattern; literal MCP tool names are required`,
129
129
  );
130
130
  }
131
131
  }
@@ -12,11 +12,9 @@
12
12
  * pre-registration required.
13
13
  * - Undeclared model/thinking/instructions never enter the plan, so Pi's
14
14
  * current state stays untouched.
15
- * - MCP references (`mcp`) expand against the server names the launcher
16
- * discovered from pi-mcp-adapter's pi-native config files: literal misses
17
- * fail loudly; globs expand to zero or more matches (consistent with
18
- * skills/extensions). Adapter presence is checked separately by the
19
- * launcher (ADR-0002).
15
+ * - MCP references (`mcps`) expand against the merged user-level MCP
16
+ * configuration snapshot: literal misses fail loudly; globs expand to zero
17
+ * or more matches (consistent with skills/extensions).
20
18
  * - Tool globs expand against Pi's built-in tool names for settings
21
19
  * `defaultTools`; raw tool references are also carried into the launch
22
20
  * plan so the in-session extension can expand them against Pi's live
@@ -28,7 +26,7 @@ import { minimatch } from "minimatch";
28
26
 
29
27
  import type { DiscoveredExtensions } from "./extension-discovery.ts";
30
28
  import { isRecord } from "./json-file.ts";
31
- import { isAdapterExtension, type MergedMcpResult } from "./mcp-config.ts";
29
+ import type { MergedMcpResult } from "./mcp-config.ts";
32
30
  import type { ProfileDefinition, ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
33
31
 
34
32
  /** Extracts a ProfileModel from flat definition keys, if declared. */
@@ -83,11 +81,10 @@ export interface ActivationPlan {
83
81
  * which Pi natively appends to the system prompt. */
84
82
  instructions?: string;
85
83
  /** Expanded MCP server allowlist: written by SettingsGenerator into the
86
- * filtered instance `mcp.json` (the adapter's runtime restriction) and
87
- * surfaced in `/profile status`; undefined when the profile declares
88
- * no `mcps` (no restriction). */
84
+ * instance `mcp.json` snapshot and surfaced in `/profile status`;
85
+ * undefined when the profile declares no `mcps` (no restriction). */
89
86
  mcps?: string[];
90
- /** Per-server MCP tool policy: literal adapter selectors per server. */
87
+ /** Per-server MCP tool policy: literal tool names per server. */
91
88
  mcpTools?: Record<string, string[]>;
92
89
  /** Prepared in-memory instance mcp.json config; never written to pi-profile.json */
93
90
  instanceMcpConfig?: Record<string, unknown>;
@@ -111,10 +108,11 @@ export interface ResolveInput {
111
108
  */
112
109
  validateModel?: (model: ProfileModel) => Promise<string | undefined>;
113
110
  /**
114
- * Server names discovered from pi-mcp-adapter's pi-native config files
115
- * (see mcp-config.ts). Required when the profile declares `mcp`: without
116
- * the discovered names a reference cannot be validated, so activation
117
- * fails rather than passing references through unchecked.
111
+ * Server names discovered from the merged user-level MCP configuration
112
+ * snapshot (see mcp-config.ts). Required when the profile declares `mcps`
113
+ * or `mcp_tools`: without the discovered names a reference cannot be
114
+ * validated, so activation fails rather than passing references through
115
+ * unchecked.
118
116
  */
119
117
  discoveredMcpServers?: string[];
120
118
  /**
@@ -144,11 +142,10 @@ function isGlob(reference: string): boolean {
144
142
  }
145
143
 
146
144
  /** Expands overlay disable entries against the names the profile resolved.
147
- * Entries are names or globs stored as written, re-expanded at every
148
- * resolution with the same matcher as profile references (ADR-0009 tiering):
149
- * an unmatched literal fails and identifies the entry; a zero-match glob is
150
- * reported through `onZeroMatch` instead of failing. Returns the resolved
151
- * names to disable. */
145
+ * Entries are names or globs stored as written and
146
+ * re-expanded at every resolution; unmatched literals fail and identify
147
+ * the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
148
+ * Overlays can disable any resolved extension. */
152
149
  function expandDisableEntries(
153
150
  entries: string[],
154
151
  activeNames: readonly string[],
@@ -228,18 +225,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
228
225
  input.discoveredMcpServers = Object.keys(input.mcpDiscovery.servers).sort();
229
226
  }
230
227
 
231
- let mcps: string[] | undefined;
232
- if (definition.mcps !== undefined && definition.mcps.length > 0) {
233
- if (input.discoveredMcpServers === undefined) {
234
- throw new ActivationError(
235
- `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
236
- );
237
- }
238
- mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
239
- onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
240
- });
241
- }
242
-
243
228
  let selectedSkills = expandReferences(definition.skills ?? [], skills, (skill) => skill.name, "skill", {
244
229
  onZeroMatch: (reference) => unmatched.push(`skill:${reference}`),
245
230
  });
@@ -255,12 +240,41 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
255
240
  }),
256
241
  );
257
242
 
243
+ let mcps: string[] | undefined;
244
+ if (definition.mcps !== undefined) {
245
+ if (definition.mcps.length > 0) {
246
+ if (input.discoveredMcpServers === undefined) {
247
+ throw new ActivationError(
248
+ `profile "${profile.name}" declares MCP servers but no MCP server discovery is available`,
249
+ );
250
+ }
251
+ mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
252
+ onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
253
+ });
254
+ if (input.mcpDiscovery !== undefined) {
255
+ const discovery = input.mcpDiscovery;
256
+ const projectNamed = mcps.find(
257
+ (name) => discovery.serverOwners[name] === "project" || discovery.projectServers.has(name),
258
+ );
259
+ if (projectNamed !== undefined) {
260
+ throw new ActivationError(
261
+ `profile "${profile.name}": cannot select project-level MCP server "${projectNamed}" (project-level servers are outside profile selection)`,
262
+ );
263
+ }
264
+ }
265
+ } else {
266
+ // Explicitly empty mcps: requires discovery so the empty selection
267
+ // disables every discovered user-level server.
268
+ if (input.mcpDiscovery === undefined) {
269
+ throw new ActivationError(
270
+ `profile "${profile.name}" declares an empty MCP server selection but no MCP server discovery is available`,
271
+ );
272
+ }
273
+ mcps = [];
274
+ }
275
+ }
276
+
258
277
  // --- overlay narrowing (ticket 06) ---
259
- // Overlay disable entries are names or globs stored as written and
260
- // re-expanded at every resolution; unmatched literals fail and identify
261
- // the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
262
- // Overlays can disable any resolved extension.
263
- const toolReferences = definition.tools;
264
278
  if (overlay !== undefined) {
265
279
  if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
266
280
  const disabled = expandDisableEntries(
@@ -306,16 +320,9 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
306
320
 
307
321
  let mcpTools: Record<string, string[]> | undefined;
308
322
  if (mcpToolKeys.length > 0) {
309
- const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
310
- if (!hasAdapter) {
311
- throw new ActivationError(
312
- `profile "${profile.name}" declares MCP tools but pi-mcp-adapter is not active. ` +
313
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`,
314
- );
315
- }
316
323
  if (input.mcpDiscovery === undefined) {
317
324
  throw new ActivationError(
318
- `profile "${profile.name}" declares MCP tools but no adapter server discovery is available`,
325
+ `profile "${profile.name}" declares MCP tools but no MCP server discovery is available`,
319
326
  );
320
327
  }
321
328
 
@@ -326,7 +333,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
326
333
  Object.hasOwn(mcpDiscovery.serverOwners, s) &&
327
334
  mcpDiscovery.serverOwners[s] === "user" &&
328
335
  !mcpDiscovery.projectServers.has(s);
329
- const isEnabled = mcpDiscovery.servers[s]?.disabled !== true;
336
+ const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
330
337
  const isAllowedByMcps = mcps === undefined || mcps.includes(s);
331
338
  return isUser && isEnabled && isAllowedByMcps;
332
339
  })
@@ -345,7 +352,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
345
352
  );
346
353
  }
347
354
  const isDisabled =
348
- mcpDiscovery.servers[serverKey]?.disabled === true ||
355
+ mcpDiscovery.servers[serverKey]?.enabled === false ||
349
356
  (mcps !== undefined && !mcps.includes(serverKey));
350
357
  if (isDisabled) {
351
358
  throw new ActivationError(`profile "${profile.name}": MCP server "${serverKey}" is disabled${candidateMsg}`);
@@ -357,6 +364,8 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
357
364
  );
358
365
  }
359
366
 
367
+ const toolReferences = definition.tools;
368
+
360
369
  let instanceMcpConfig: Record<string, unknown> | undefined;
361
370
  if (input.mcpDiscovery !== undefined && (mcps !== undefined || mcpToolKeys.length > 0)) {
362
371
  instanceMcpConfig = buildInstanceMcpConfig(profile.name, input.mcpDiscovery, mcps, mcpTools);
@@ -439,32 +448,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
439
448
  };
440
449
  }
441
450
 
442
- function computeSafeMcpToolIntersection(
443
- profileName: string,
444
- serverName: string,
445
- requestedTools: string[],
446
- existingIncludes?: string[],
447
- ): string[] {
448
- if (
449
- requestedTools.length === 0 ||
450
- existingIncludes === undefined ||
451
- existingIncludes.length === 0 ||
452
- existingIncludes.includes("*")
453
- ) {
454
- return [...requestedTools];
455
- }
456
-
457
- const unsafeSelector = requestedTools.find((selector) => !existingIncludes.includes(selector));
458
- if (unsafeSelector !== undefined) {
459
- throw new ActivationError(
460
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": ` +
461
- `selector ${JSON.stringify(unsafeSelector)} is not an exact member of existing includeTools; ` +
462
- `use identical literals or an existing "*" allowlist to prove the restriction is safe`,
463
- );
464
- }
465
- return [...requestedTools];
466
- }
467
-
468
451
  function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
469
452
  Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
470
453
  }
@@ -488,61 +471,42 @@ export function buildInstanceMcpConfig(
488
471
  const originalDef = Object.hasOwn(mcpDiscovery.servers, serverName)
489
472
  ? mcpDiscovery.servers[serverName]
490
473
  : undefined;
491
- if (originalDef !== undefined) {
492
- const def = { ...originalDef };
493
- if (mcps !== undefined) {
494
- delete def.disabled;
495
- }
474
+ if (originalDef === undefined) continue;
496
475
 
497
- if (mcpTools && Object.hasOwn(mcpTools, serverName)) {
498
- if (
499
- def.includeTools !== undefined &&
500
- (!Array.isArray(def.includeTools) || !def.includeTools.every((t) => typeof t === "string"))
501
- ) {
502
- throw new ActivationError(
503
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing includeTools`,
504
- );
505
- }
506
- if (
507
- def.excludeTools !== undefined &&
508
- (!Array.isArray(def.excludeTools) || !def.excludeTools.every((t) => typeof t === "string"))
509
- ) {
510
- throw new ActivationError(
511
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing excludeTools`,
512
- );
513
- }
476
+ // Project-owned servers are never materialized into the snapshot;
477
+ // Pi reads the trusted project's .pi/mcp.json itself.
478
+ const isProjectOwned =
479
+ mcpDiscovery.serverOwners[serverName] === "project" || mcpDiscovery.projectServers.has(serverName);
480
+ if (isProjectOwned) continue;
514
481
 
515
- const requested = mcpTools[serverName];
516
- const existingIncludes = Array.isArray(def.includeTools) ? (def.includeTools as string[]) : undefined;
517
- const existingExcludes = Array.isArray(def.excludeTools) ? (def.excludeTools as string[]) : undefined;
482
+ // A server explicitly selected by mcps whose definition uses a
483
+ // transport Pi's built-in MCP extension cannot use fails activation.
484
+ if (mcps?.includes(serverName) === true && originalDef.type === "sse") {
485
+ throw new ActivationError(
486
+ `profile "${profileName}": selected MCP server "${serverName}" uses the legacy SSE transport, which Pi does not support; switch to the server's streamable HTTP URL or remove it from "mcps"`,
487
+ );
488
+ }
518
489
 
519
- const safeTools = computeSafeMcpToolIntersection(
520
- profileName,
521
- serverName,
522
- requested,
523
- existingIncludes,
524
- );
490
+ const def = { ...originalDef };
525
491
 
526
- if (safeTools.length === 0) {
527
- delete def.includeTools;
528
- if (existingExcludes === undefined || !existingExcludes.includes("*")) {
529
- def.excludeTools = [...(existingExcludes ?? []), "*"];
530
- }
531
- } else {
532
- def.includeTools = safeTools;
533
- }
492
+ if (mcpTools && Object.hasOwn(mcpTools, serverName)) {
493
+ // Profile policy replaces the merged toolExposure wholesale.
494
+ const requested = mcpTools[serverName];
495
+ const exposure: Record<string, string> = { "*": "hidden" };
496
+ for (const selector of requested) {
497
+ exposure[selector] = "direct";
534
498
  }
535
-
536
- setOwnRecordValue(filteredServers, serverName, def);
499
+ def.toolExposure = exposure;
537
500
  }
501
+
502
+ setOwnRecordValue(filteredServers, serverName, def);
538
503
  }
539
504
 
505
+ // Unselected user-level servers are explicitly disabled.
540
506
  if (mcps !== undefined) {
541
- for (const sharedName of mcpDiscovery.sharedServers) {
542
- if (!mcps.includes(sharedName)) {
543
- if (mcpDiscovery.projectServers.has(sharedName)) continue;
544
- setOwnRecordValue(filteredServers, sharedName, { disabled: true });
545
- }
507
+ for (const userName of userServers) {
508
+ if (mcps.includes(userName)) continue;
509
+ setOwnRecordValue(filteredServers, userName, { enabled: false });
546
510
  }
547
511
  }
548
512
 
@@ -430,37 +430,23 @@ export async function writeRuntimeFiles(
430
430
  await symlink(path.join(options.agentDir, "trust.json"), trustLink);
431
431
  }
432
432
 
433
- // MCP Servers generation (Ticket 04 & Separate MCP tool filtering)
434
- const mcpTarget = path.join(options.agentDir, "mcp.json");
433
+ // MCP Servers generation: the instance mcp.json is always a generated
434
+ // snapshot of the merged user-level configuration; it is never a symlink
435
+ // or a copy of the real agentDir file (ADR-0016).
435
436
  const mcpInstancePath = path.join(runtimeDir, "mcp.json");
436
- if (plan.instanceMcpConfig !== undefined) {
437
- try { await rm(mcpInstancePath); } catch {}
438
- await writeFile(mcpInstancePath, JSON.stringify(plan.instanceMcpConfig, null, 2));
439
- } else if (
440
- plan.mcps === undefined &&
441
- (plan.mcpTools === undefined || Object.keys(plan.mcpTools).length === 0)
442
- ) {
443
- // No restrictions, symlink
444
- if (await exists(mcpTarget)) {
445
- try { await rm(mcpInstancePath); } catch {}
446
- await symlink(mcpTarget, mcpInstancePath);
447
- }
448
- } else {
449
- // Filter MCP servers (fallback when instanceMcpConfig is not pre-populated)
450
- try { await rm(mcpInstancePath); } catch {}
451
- const discovery = await loadMergedMcpServers(
452
- options.agentDir,
453
- options.projectDir,
454
- options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
455
- );
456
- const instanceMcpConfig = buildInstanceMcpConfig(
457
- plan.profile,
458
- discovery,
459
- plan.mcps,
460
- plan.mcpTools,
461
- );
462
- await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
463
- }
437
+ try { await rm(mcpInstancePath); } catch {}
438
+ const discovery = await loadMergedMcpServers(
439
+ options.agentDir,
440
+ options.projectDir,
441
+ options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
442
+ );
443
+ const instanceMcpConfig = buildInstanceMcpConfig(
444
+ plan.profile,
445
+ discovery,
446
+ plan.mcps,
447
+ plan.mcpTools,
448
+ );
449
+ await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
464
450
 
465
451
  // Instructions generation (Ticket 04)
466
452
  const appendSystemPath = path.join(runtimeDir, "APPEND_SYSTEM.md");
@@ -10,7 +10,7 @@
10
10
  * Steps:
11
11
  * 1. tools: re-expand the profile's raw references against Pi's LIVE
12
12
  * non-MCP tool registry and call setActiveTools while retaining
13
- * adapter-owned registrations. Settings `defaultTools` provides only
13
+ * MCP-owned registrations. Settings `defaultTools` provides only
14
14
  * the boot baseline for built-ins. Literals that no Pi tool provides
15
15
  * are dropped with a warning — Pi silently ignores unknown names, so
16
16
  * the warning is the only signal.
@@ -26,8 +26,8 @@
26
26
  * for the next agent turn and is cleared from the plan file.
27
27
  *
28
28
  * Pi's reload re-executes extension modules, so no stale handler or command
29
- * context survives; this module is the only place post-reload state is
30
- * established.
29
+ * context survives; this module is the only place post-reload state is
30
+ * established.
31
31
  */
32
32
 
33
33
  import { readFile, writeFile } from "node:fs/promises";
@@ -90,68 +90,13 @@ export async function readLaunchPlanFile(runtimeDir: string): Promise<LaunchPlan
90
90
  return result.value as unknown as LaunchPlanFile;
91
91
  }
92
92
 
93
- interface AdapterAttribution {
94
- /** Exact adapter entry paths (loose adapter files). */
95
- exactEntries: string[];
96
- /** Verified pi-mcp-adapter npm package roots. */
97
- packageRoots: string[];
98
- }
99
-
100
- async function resolveAdapterPackageRoot(entryPath: string): Promise<string | undefined> {
101
- let current = path.resolve(entryPath);
102
- while (true) {
103
- const parent = path.dirname(current);
104
- if (parent === current) return undefined;
105
- current = parent;
106
- try {
107
- const manifest: unknown = JSON.parse(await readFile(path.join(current, "package.json"), "utf8"));
108
- if (isRecord(manifest) && manifest.name === "pi-mcp-adapter") {
109
- return current;
110
- }
111
- } catch {
112
- // Continue walking toward the filesystem root.
113
- }
114
- }
115
- }
116
-
117
- async function getAdapterAttribution(plan: LaunchPlanFile): Promise<AdapterAttribution> {
118
- const exactEntries: string[] = [];
119
- const packageRoots: string[] = [];
120
- for (const ext of plan.resolved?.extensions ?? []) {
121
- if (ext.origin === "package") {
122
- const packageName = ext.id.split(":", 1)[0]!;
123
- if (packageName === "pi-mcp-adapter") {
124
- const root = await resolveAdapterPackageRoot(ext.entry);
125
- if (root !== undefined) {
126
- packageRoots.push(root);
127
- }
128
- }
129
- } else if (ext.id === "pi-mcp-adapter") {
130
- // Loose adapter files (and plan entries without an origin marker)
131
- // own only their exact entry; siblings such as
132
- // "pi-mcp-adapter/linter" do not become adapter-owned.
133
- exactEntries.push(ext.entry);
134
- }
135
- }
136
- return { exactEntries, packageRoots };
137
- }
138
-
139
- export function isMcpOwnedTool(
140
- tool: { name: string; sourceInfo?: { path?: string; source?: string } },
141
- attribution?: AdapterAttribution,
142
- ): boolean {
93
+ export function isMcpOwnedTool(tool: {
94
+ name: string;
95
+ sourceInfo?: { path?: string; source?: string };
96
+ }): boolean {
143
97
  const info = tool.sourceInfo;
144
98
  if (!info) return false;
145
- if (info.source === "pi-mcp-adapter" || info.source === "mcp") return true;
146
- if (typeof info.path !== "string" || attribution === undefined) return false;
147
- if (attribution.exactEntries.includes(info.path)) return true;
148
- for (const root of attribution.packageRoots) {
149
- const rel = path.relative(root, info.path);
150
- if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) {
151
- return true;
152
- }
153
- }
154
- return false;
99
+ return info.path === "builtin:mcp";
155
100
  }
156
101
 
157
102
  /** Applies the plan carried by the runtime dir's pi-profile.json. */
@@ -173,12 +118,11 @@ export async function applyLaunchPlan(input: {
173
118
  const hasOverlayDisables = plan.disabledTools !== undefined && plan.disabledTools.length > 0;
174
119
  if (plan.toolReferences !== undefined || hasOverlayDisables) {
175
120
  const allTools = surface.getAllTools();
176
- const attribution = await getAdapterAttribution(plan);
177
121
  const mcpToolNames: string[] = [];
178
122
  const nonMcpToolNames: string[] = [];
179
123
 
180
124
  for (const tool of allTools) {
181
- if (isMcpOwnedTool(tool, attribution)) {
125
+ if (isMcpOwnedTool(tool)) {
182
126
  mcpToolNames.push(tool.name);
183
127
  } else {
184
128
  nonMcpToolNames.push(tool.name);
@@ -2,10 +2,10 @@
2
2
  * StatusReport: the `/profile status` observability surface (ticket 07).
3
3
  *
4
4
  * Pure report builder: combines the ACTIVE launch plan (what the runtime
5
- * was resolved to), the stored overlay, fresh MCP adapter discovery, and
6
- * Pi's actual command registrations (the winner evidence for same-name
7
- * conflicts). Markdown formatting is the only presentation; the extension
8
- * ships it via `pi.sendMessage`.
5
+ * was resolved to), the stored overlay, fresh MCP discovery from the merged
6
+ * user-level snapshot, and Pi's actual command registrations (the winner
7
+ * evidence for same-name conflicts). Markdown formatting is the only
8
+ * presentation; the extension ships it via `pi.sendMessage`.
9
9
  *
10
10
  * Conflict semantics: Pi's load order is first-wins by scope/file order,
11
11
  * so the registered command IS the winner. A conflict is reported when the
@@ -73,6 +73,9 @@ export function buildStatusReport(input: {
73
73
  overlay?: RuntimeOverlay;
74
74
  discoveredMcpServers: string[];
75
75
  disabledMcpServers: string[];
76
+ /** Project-owned server names from trust-gated MCP snapshot discovery;
77
+ * kept enabled when the profile declares a defined `mcps` selection. */
78
+ projectMcpServers?: string[];
76
79
  commands: RegisteredCommand[];
77
80
  tools: RegisteredTool[];
78
81
  }): StatusReport {
@@ -84,14 +87,20 @@ export function buildStatusReport(input: {
84
87
  let missing: string[];
85
88
 
86
89
  if (plan.mcps === undefined) {
87
- const adapterDisabled = new Set(input.disabledMcpServers);
88
- enabled = input.discoveredMcpServers.filter((name) => !adapterDisabled.has(name));
89
- disabled = input.discoveredMcpServers.filter((name) => adapterDisabled.has(name));
90
+ const snapshotDisabled = new Set(input.disabledMcpServers);
91
+ enabled = input.discoveredMcpServers.filter((name) => !snapshotDisabled.has(name));
92
+ disabled = input.discoveredMcpServers.filter((name) => snapshotDisabled.has(name));
90
93
  missing = [];
91
94
  } else {
92
- enabled = plan.mcps;
95
+ const projectMcpServers = new Set(input.projectMcpServers ?? []);
96
+ const snapshotDisabled = new Set(input.disabledMcpServers);
97
+ const explicitMcps = new Set(plan.mcps);
98
+ const projectEnabled = input.discoveredMcpServers.filter(
99
+ (name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !snapshotDisabled.has(name),
100
+ );
101
+ enabled = [...plan.mcps, ...projectEnabled].sort();
93
102
  disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));
94
- missing = enabled.filter((name) => !discovered.has(name));
103
+ missing = plan.mcps.filter((name) => !discovered.has(name));
95
104
  }
96
105
 
97
106
  const mcp = { enabled, disabled, missing };