pi-profile-switch 0.12.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,15 +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.
103
- - **Omitting `mcps`** leaves all discovered user-level servers at their normal adapter-defined availability.
104
- - **`mcps: []` with `pi-mcp-adapter` selected** disables every discovered user-level server (shared locations are explicitly marked `disabled`; agentDir-only servers are omitted from the generated instance `mcp.json`).
105
- - **`mcps: []` without `pi-mcp-adapter`** is inert: it does not read MCP configuration, change server availability, or fail because of a malformed config file.
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.
106
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.
107
105
  - Any field you omit keeps plain Pi behavior.
108
106
 
109
107
  The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
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,7 +227,7 @@ 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
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, all discovered user-level servers keep their normal adapter availability and `mcps` adds no adapter requirement; a nonempty `mcp_tools` still requires `pi-mcp-adapter`. An explicit `mcps: []` disables every discovered user-level server when `pi-mcp-adapter` is selected, and is inert when the adapter is not selected. Project-level servers are never narrowed. |
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,9 +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
- "pi-mcp-adapter"
75
- ],
76
73
  "mcps": [],
77
74
  "tools": [
78
75
  "read",
@@ -106,17 +103,17 @@ When helping the user configure a profile, check or consult the following locati
106
103
  - Absolute paths or `~/` paths.
107
104
  - Glob matching.
108
105
  3. **MCP servers**:
109
- - 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`.
110
107
  - Reference identity: the server key names under the `mcpServers` object in those configuration files.
111
- - 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.
112
109
  4. **Tools**:
113
110
  - Reference identity: non-MCP tool names in Pi's live tool registry.
114
111
  - Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.) and extension-contributed tools.
115
112
  - MCP-owned tools are **not** controlled by `tools`; legacy references such as `"mcp__*"` or `"github_*"` in `tools` must be migrated to `mcp_tools`.
116
113
  5. **MCP Tools (`mcp_tools`)**:
117
- - 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.
118
- - 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.
119
- - 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.
120
117
  - Omitting a server key preserves unrestricted access to that server's tools.
121
118
  - An empty list (`[]`) denies all tools for that server while keeping the server enabled.
122
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 = {
@@ -163,15 +163,8 @@ export async function resolveInitialProfile(
163
163
  const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
164
164
  const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
165
165
  const declaredMcps = profile.definition.mcps;
166
- const isEmptyMcps = declaredMcps !== undefined && declaredMcps.length === 0;
167
- let adapterSelectedForEmptyMcps = false;
168
- if (isEmptyMcps) {
169
- const extSelection = await discovery.extensions.select(profile.definition.extensions ?? []);
170
- adapterSelectedForEmptyMcps = extSelection.entries.some(isAdapterExtension);
171
- }
172
166
  const needsMcp = Boolean(
173
- (declaredMcps?.length ?? 0) > 0 ||
174
- (isEmptyMcps && adapterSelectedForEmptyMcps) ||
167
+ declaredMcps !== undefined ||
175
168
  hasMcpTools ||
176
169
  (options?.overlay?.disabledMcps?.length ?? 0) > 0,
177
170
  );
@@ -190,15 +183,5 @@ export async function resolveInitialProfile(
190
183
  liveToolNames: options?.liveToolNames,
191
184
  });
192
185
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
193
- if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
194
- throw new MissingMcpAdapterError(plan.profile, "mcps");
195
- }
196
- if (
197
- plan.mcpTools !== undefined &&
198
- Object.keys(plan.mcpTools).length > 0 &&
199
- !plan.extensions.some(isAdapterExtension)
200
- ) {
201
- throw new MissingMcpAdapterError(plan.profile, "mcp_tools");
202
- }
203
186
  return { plan, discovery, projectDir, projectTrusted, warnings };
204
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[],
@@ -233,9 +230,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
233
230
  });
234
231
 
235
232
  // Extension references resolve directly against discovered extensions.
236
- // Resolve extensions before MCP so an explicitly empty `mcps` array can
237
- // be distinguished from omission based on whether the profile selects the
238
- // adapter (honor-empty-mcp-allowlist).
239
233
  const selection = await extensions.select(definition.extensions ?? []);
240
234
  for (const reference of selection.unmatched) unmatched.push(`extension:${reference}`);
241
235
  let planExtensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }> = selection.entries.map(
@@ -246,23 +240,34 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
246
240
  }),
247
241
  );
248
242
 
249
- const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
250
-
251
243
  let mcps: string[] | undefined;
252
244
  if (definition.mcps !== undefined) {
253
245
  if (definition.mcps.length > 0) {
254
246
  if (input.discoveredMcpServers === undefined) {
255
247
  throw new ActivationError(
256
- `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
248
+ `profile "${profile.name}" declares MCP servers but no MCP server discovery is available`,
257
249
  );
258
250
  }
259
251
  mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
260
252
  onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
261
253
  });
262
- } else if (hasAdapter) {
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.
263
268
  if (input.mcpDiscovery === undefined) {
264
269
  throw new ActivationError(
265
- `profile "${profile.name}" declares MCP servers but no adapter server discovery is available`,
270
+ `profile "${profile.name}" declares an empty MCP server selection but no MCP server discovery is available`,
266
271
  );
267
272
  }
268
273
  mcps = [];
@@ -270,11 +275,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
270
275
  }
271
276
 
272
277
  // --- overlay narrowing (ticket 06) ---
273
- // Overlay disable entries are names or globs stored as written and
274
- // re-expanded at every resolution; unmatched literals fail and identify
275
- // the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
276
- // Overlays can disable any resolved extension.
277
- const toolReferences = definition.tools;
278
278
  if (overlay !== undefined) {
279
279
  if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
280
280
  const disabled = expandDisableEntries(
@@ -320,16 +320,9 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
320
320
 
321
321
  let mcpTools: Record<string, string[]> | undefined;
322
322
  if (mcpToolKeys.length > 0) {
323
- const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
324
- if (!hasAdapter) {
325
- throw new ActivationError(
326
- `profile "${profile.name}" declares MCP tools but pi-mcp-adapter is not active. ` +
327
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`,
328
- );
329
- }
330
323
  if (input.mcpDiscovery === undefined) {
331
324
  throw new ActivationError(
332
- `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`,
333
326
  );
334
327
  }
335
328
 
@@ -340,7 +333,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
340
333
  Object.hasOwn(mcpDiscovery.serverOwners, s) &&
341
334
  mcpDiscovery.serverOwners[s] === "user" &&
342
335
  !mcpDiscovery.projectServers.has(s);
343
- const isEnabled = mcpDiscovery.servers[s]?.disabled !== true;
336
+ const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
344
337
  const isAllowedByMcps = mcps === undefined || mcps.includes(s);
345
338
  return isUser && isEnabled && isAllowedByMcps;
346
339
  })
@@ -359,7 +352,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
359
352
  );
360
353
  }
361
354
  const isDisabled =
362
- mcpDiscovery.servers[serverKey]?.disabled === true ||
355
+ mcpDiscovery.servers[serverKey]?.enabled === false ||
363
356
  (mcps !== undefined && !mcps.includes(serverKey));
364
357
  if (isDisabled) {
365
358
  throw new ActivationError(`profile "${profile.name}": MCP server "${serverKey}" is disabled${candidateMsg}`);
@@ -371,6 +364,8 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
371
364
  );
372
365
  }
373
366
 
367
+ const toolReferences = definition.tools;
368
+
374
369
  let instanceMcpConfig: Record<string, unknown> | undefined;
375
370
  if (input.mcpDiscovery !== undefined && (mcps !== undefined || mcpToolKeys.length > 0)) {
376
371
  instanceMcpConfig = buildInstanceMcpConfig(profile.name, input.mcpDiscovery, mcps, mcpTools);
@@ -453,32 +448,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
453
448
  };
454
449
  }
455
450
 
456
- function computeSafeMcpToolIntersection(
457
- profileName: string,
458
- serverName: string,
459
- requestedTools: string[],
460
- existingIncludes?: string[],
461
- ): string[] {
462
- if (
463
- requestedTools.length === 0 ||
464
- existingIncludes === undefined ||
465
- existingIncludes.length === 0 ||
466
- existingIncludes.includes("*")
467
- ) {
468
- return [...requestedTools];
469
- }
470
-
471
- const unsafeSelector = requestedTools.find((selector) => !existingIncludes.includes(selector));
472
- if (unsafeSelector !== undefined) {
473
- throw new ActivationError(
474
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": ` +
475
- `selector ${JSON.stringify(unsafeSelector)} is not an exact member of existing includeTools; ` +
476
- `use identical literals or an existing "*" allowlist to prove the restriction is safe`,
477
- );
478
- }
479
- return [...requestedTools];
480
- }
481
-
482
451
  function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
483
452
  Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
484
453
  }
@@ -502,61 +471,42 @@ export function buildInstanceMcpConfig(
502
471
  const originalDef = Object.hasOwn(mcpDiscovery.servers, serverName)
503
472
  ? mcpDiscovery.servers[serverName]
504
473
  : undefined;
505
- if (originalDef !== undefined) {
506
- const def = { ...originalDef };
507
- if (mcps !== undefined) {
508
- delete def.disabled;
509
- }
474
+ if (originalDef === undefined) continue;
510
475
 
511
- if (mcpTools && Object.hasOwn(mcpTools, serverName)) {
512
- if (
513
- def.includeTools !== undefined &&
514
- (!Array.isArray(def.includeTools) || !def.includeTools.every((t) => typeof t === "string"))
515
- ) {
516
- throw new ActivationError(
517
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing includeTools`,
518
- );
519
- }
520
- if (
521
- def.excludeTools !== undefined &&
522
- (!Array.isArray(def.excludeTools) || !def.excludeTools.every((t) => typeof t === "string"))
523
- ) {
524
- throw new ActivationError(
525
- `profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing excludeTools`,
526
- );
527
- }
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;
528
481
 
529
- const requested = mcpTools[serverName];
530
- const existingIncludes = Array.isArray(def.includeTools) ? (def.includeTools as string[]) : undefined;
531
- 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
+ }
532
489
 
533
- const safeTools = computeSafeMcpToolIntersection(
534
- profileName,
535
- serverName,
536
- requested,
537
- existingIncludes,
538
- );
490
+ const def = { ...originalDef };
539
491
 
540
- if (safeTools.length === 0) {
541
- delete def.includeTools;
542
- if (existingExcludes === undefined || !existingExcludes.includes("*")) {
543
- def.excludeTools = [...(existingExcludes ?? []), "*"];
544
- }
545
- } else {
546
- def.includeTools = safeTools;
547
- }
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";
548
498
  }
549
-
550
- setOwnRecordValue(filteredServers, serverName, def);
499
+ def.toolExposure = exposure;
551
500
  }
501
+
502
+ setOwnRecordValue(filteredServers, serverName, def);
552
503
  }
553
504
 
505
+ // Unselected user-level servers are explicitly disabled.
554
506
  if (mcps !== undefined) {
555
- for (const sharedName of mcpDiscovery.sharedServers) {
556
- if (!mcps.includes(sharedName)) {
557
- if (mcpDiscovery.projectServers.has(sharedName)) continue;
558
- setOwnRecordValue(filteredServers, sharedName, { disabled: true });
559
- }
507
+ for (const userName of userServers) {
508
+ if (mcps.includes(userName)) continue;
509
+ setOwnRecordValue(filteredServers, userName, { enabled: false });
560
510
  }
561
511
  }
562
512
 
@@ -170,6 +170,29 @@ function homeAgentsSkillsDir(): string {
170
170
  return path.join(process.env.HOME ?? homedir(), ".agents", "skills");
171
171
  }
172
172
 
173
+ /** The user's own skill exclusions (`!pattern`, `-path`), carried into a
174
+ * named profile's generated settings. Relative entries resolve against the
175
+ * runtime dir, which mirrors the agent dir, so they pass through unchanged.
176
+ * An absolute (or `~`) `-path` under the agent dir is rewritten to its
177
+ * runtime mirror path: Pi matches `-` entries lexically. */
178
+ function userSkillExclusions(userSkills: unknown, agentDir: string, runtimeDir: string): string[] {
179
+ if (!Array.isArray(userSkills)) return [];
180
+ const exclusions: string[] = [];
181
+ for (const entry of userSkills) {
182
+ if (typeof entry !== "string") continue;
183
+ if (entry.startsWith("!")) {
184
+ exclusions.push(entry);
185
+ } else if (entry.startsWith("-")) {
186
+ const target = entry.slice(1);
187
+ const expanded = target === "~" || target.startsWith("~/") ? path.join(process.env.HOME ?? homedir(), target.slice(1)) : target;
188
+ const rel = path.isAbsolute(expanded) ? path.relative(agentDir, expanded) : undefined;
189
+ const underAgentDir = rel !== undefined && rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
190
+ exclusions.push(underAgentDir ? `-${path.join(runtimeDir, rel)}` : entry);
191
+ }
192
+ }
193
+ return exclusions;
194
+ }
195
+
173
196
  function buildSelectionSettings(
174
197
  plan: ActivationPlan,
175
198
  userSettings: Record<string, unknown>,
@@ -213,6 +236,11 @@ function buildSelectionSettings(
213
236
  skillEntries.push(`-${skill.filePath}`);
214
237
  }
215
238
  }
239
+ // Discovery already honored the user's own `!pattern` / `-path` skill
240
+ // exclusions, so the skills they hide never reach the loop above and get no
241
+ // `-` entry. Replacing the user's array would then drop those exclusions and
242
+ // let the instance's skills symlink and ~/.agents/skills reveal them.
243
+ skillEntries.push(...userSkillExclusions(userSettings.skills, agentDir, runtimeDir));
216
244
  settings.skills = skillEntries;
217
245
 
218
246
  // --- extensions ---
@@ -430,37 +458,23 @@ export async function writeRuntimeFiles(
430
458
  await symlink(path.join(options.agentDir, "trust.json"), trustLink);
431
459
  }
432
460
 
433
- // MCP Servers generation (Ticket 04 & Separate MCP tool filtering)
434
- const mcpTarget = path.join(options.agentDir, "mcp.json");
461
+ // MCP Servers generation: the instance mcp.json is always a generated
462
+ // snapshot of the merged user-level configuration; it is never a symlink
463
+ // or a copy of the real agentDir file (ADR-0016).
435
464
  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
- }
465
+ 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
+ await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
464
478
 
465
479
  // Instructions generation (Ticket 04)
466
480
  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,8 +73,8 @@ 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 discovery; kept enabled
77
- * when the profile declares a defined `mcps` selection. */
76
+ /** Project-owned server names from trust-gated MCP snapshot discovery;
77
+ * kept enabled when the profile declares a defined `mcps` selection. */
78
78
  projectMcpServers?: string[];
79
79
  commands: RegisteredCommand[];
80
80
  tools: RegisteredTool[];
@@ -87,16 +87,16 @@ export function buildStatusReport(input: {
87
87
  let missing: string[];
88
88
 
89
89
  if (plan.mcps === undefined) {
90
- const adapterDisabled = new Set(input.disabledMcpServers);
91
- enabled = input.discoveredMcpServers.filter((name) => !adapterDisabled.has(name));
92
- 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));
93
93
  missing = [];
94
94
  } else {
95
95
  const projectMcpServers = new Set(input.projectMcpServers ?? []);
96
- const adapterDisabled = new Set(input.disabledMcpServers);
96
+ const snapshotDisabled = new Set(input.disabledMcpServers);
97
97
  const explicitMcps = new Set(plan.mcps);
98
98
  const projectEnabled = input.discoveredMcpServers.filter(
99
- (name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !adapterDisabled.has(name),
99
+ (name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !snapshotDisabled.has(name),
100
100
  );
101
101
  enabled = [...plan.mcps, ...projectEnabled].sort();
102
102
  disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));