pi-profile-switch 0.11.0 → 0.12.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh-CN.md)
4
4
 
5
- Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named set of resources you define: skills, extensions, MCP servers, tools (including tools exposed by MCP servers and extensions), model defaults, and extra system-prompt instructions. Switch profiles inside a running Pi session — no restart.
5
+ Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named set of resources you define: skills, extensions, MCP servers, tools, per-server MCP tool selections (`mcp_tools`), model defaults, and extra system-prompt instructions. Switch profiles inside a running Pi session — no restart.
6
6
 
7
7
  ## Install
8
8
 
@@ -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 plus both servers' MCP tools by glob; 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, 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:
55
55
 
56
56
  ```json
57
57
  {
@@ -76,11 +76,15 @@ One profile can use every field at once. This example `impl` profile (`impl.json
76
76
  "ls",
77
77
  "bash",
78
78
  "edit",
79
- "write",
80
- "mcp__*",
81
- "github_*",
82
- "linear_*"
79
+ "write"
83
80
  ],
81
+ "mcp_tools": {
82
+ "github": [
83
+ "search",
84
+ "get_issue"
85
+ ],
86
+ "linear": []
87
+ },
84
88
  "defaultProvider": "anthropic",
85
89
  "defaultModel": "claude-sonnet-4-5",
86
90
  "defaultThinkingLevel": "high",
@@ -91,7 +95,10 @@ One profile can use every field at once. This example `impl` profile (`impl.json
91
95
  How fields resolve:
92
96
 
93
97
  - `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.
94
- - `tools` expands against Pi's live tool registry — built-ins, extension-provided tools, and tools exposed by MCP servers. MCP tools are registered as `mcp__<server>` (proxy) and `<server>_<tool>` (direct tools, the adapter's default `toolPrefix`), so globs like `mcp__*` and `github_*` cover them.
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`.
95
102
  - `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
96
103
  - Any field you omit keeps plain Pi behavior.
97
104
 
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [中文](README.zh-CN.md)
4
4
 
5
- [Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 是你自定义的命名能力组合:skills、extensions、MCP server、tools(包括 MCP server 和 extension 提供的工具)、默认模型,以及追加到系统提示词的 instructions。在同一个运行中的 Pi 会话里切换这些组合,无需重启。
5
+ [Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 是你自定义的命名能力组合:skills、extensions、MCP server、tools、按 server 细化的 MCP 工具控制(`mcp_tools`)、默认模型,以及追加到系统提示词的 instructions。在同一个运行中的 Pi 会话里切换这些组合,无需重启。
6
6
 
7
7
  ## 安装
8
8
 
@@ -51,7 +51,7 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
51
51
  }
52
52
  ```
53
53
 
54
- 一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;tool 白名单用 glob 覆盖内建工具和这两个 server 的 MCP 工具;并钉住模型与常驻 instructions:
54
+ 一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;允许内建工具并按 server 细化 MCP 工具;同时钉住模型与常驻 instructions:
55
55
 
56
56
  ```json
57
57
  {
@@ -76,11 +76,15 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
76
76
  "ls",
77
77
  "bash",
78
78
  "edit",
79
- "write",
80
- "mcp__*",
81
- "github_*",
82
- "linear_*"
79
+ "write"
83
80
  ],
81
+ "mcp_tools": {
82
+ "github": [
83
+ "search",
84
+ "get_issue"
85
+ ],
86
+ "linear": []
87
+ },
84
88
  "defaultProvider": "anthropic",
85
89
  "defaultModel": "claude-sonnet-4-5",
86
90
  "defaultThinkingLevel": "high",
@@ -91,7 +95,10 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
91
95
  字段解析规则:
92
96
 
93
97
  - `skills`、`extensions`、`mcps`、`tools` 接受名称或 glob(如 `"internal-*"`),引用你已安装或已配置的资源——profile 从不复制资源。已安装的包和标准目录下的文件会被自动发现,无需注册。
94
- - `tools` 针对 Pi 的实时工具注册表展开——内建工具、extension 提供的工具,以及 MCP server 暴露的工具。MCP 工具注册为 `mcp__<server>`(代理)和 `<server>_<tool>`(直接工具,adapter 默认 `toolPrefix`),因此 `mcp__*`、`github_*` 这类 glob 可以覆盖它们。
98
+ - `tools` 仅针对 Pi 的非 MCP 工具展开(内建工具与 extension 提供的工具,根据注册归属 `sourceInfo` 判定)。可用的 MCP 工具独立于 `tools` 保持可用。
99
+ - `mcp_tools` 按 server 细化 MCP 工具策略:键为字面 MCP server 名称,值为 pi-mcp-adapter 的字面 selector(原始名或带前缀名;两种写法都可能选中同一个工具)。此字段不接受 glob。省略的 server 保留其全部原生工具访问;非空数组仅允许匹配到的工具;空数组(`[]`)禁用该 server 的全部工具,同时保持 server 处于启用状态。未匹配的 selector 仍保持限制且不会收到诊断,因此编写前请通过 adapter/server 确认可用写法。
100
+ - 非空的 `mcp_tools` 即使在省略 `mcps` 时也需要 `pi-mcp-adapter`。只有当 profile selector 与已有的 `includeTools` 字面值完全相同,或已有列表是 `"*"` 时才能安全组合;否则会在写入 runtime 文件前报错。已有的 `excludeTools` 限制仍然生效。
101
+ - **迁移提示**:旧 profile 中写入 `tools` 的 MCP 工具名称或 glob(如 `mcp__*`、`<server>_*`)不再控制 MCP 访问;如有需要请迁移至 `mcp_tools`。
95
102
  - `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
96
103
  - 未写的字段保持原生 Pi 行为。
97
104
 
@@ -20,11 +20,15 @@
20
20
  "ls",
21
21
  "bash",
22
22
  "edit",
23
- "write",
24
- "mcp__*",
25
- "github_*",
26
- "linear_*"
23
+ "write"
27
24
  ],
25
+ "mcp_tools": {
26
+ "github": [
27
+ "github_search",
28
+ "get_issue"
29
+ ],
30
+ "linear": []
31
+ },
28
32
  "defaultProvider": "anthropic",
29
33
  "defaultModel": "claude-sonnet-4-5",
30
34
  "defaultThinkingLevel": "high",
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { isRecord } from "../../src/json-file.ts";
8
8
  import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
9
- import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
9
+ import { loadMergedMcpServers } from "../../src/mcp-config.ts";
10
10
  import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
11
11
  import { runStartupNotifications, type NoticeSurface } from "../../src/startup-notifier.ts";
12
12
  import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
@@ -221,13 +221,19 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
221
221
  const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
222
222
  const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
223
223
  const state = await new RuntimeStateStore(stateDir).read();
224
+ const mcpDiscovery = await loadMergedMcpServers(
225
+ plan.agentDir,
226
+ projectTrusted ? ctx.cwd : undefined,
227
+ );
228
+ const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
229
+ const disabledMcpServers = discoveredMcpServers.filter(
230
+ (server) => mcpDiscovery.servers[server]?.disabled === true,
231
+ );
224
232
  const report = buildStatusReport({
225
233
  plan,
226
234
  overlay: state.overlay,
227
- discoveredMcpServers: await discoverAdapterServerNames(
228
- plan.agentDir,
229
- projectTrusted ? ctx.cwd : undefined,
230
- ),
235
+ discoveredMcpServers,
236
+ disabledMcpServers,
231
237
  commands: pi.getCommands(),
232
238
  tools: pi.getAllTools(),
233
239
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.11.0",
3
+ "version": "0.12.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,6 +36,7 @@
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",
39
40
  "typescript": "^5.8.0",
40
41
  "vitest": "^3.0.0"
41
42
  },
@@ -38,7 +38,21 @@
38
38
  "items": {
39
39
  "type": "string"
40
40
  },
41
- "description": "Tool names or globs expanded against Pi's live registry (including extension tools)."
41
+ "description": "Names or globs for non-MCP Pi built-ins and non-MCP extension tools; MCP tools are selected separately with mcp_tools."
42
+ },
43
+ "mcp_tools": {
44
+ "type": "object",
45
+ "propertyNames": {
46
+ "pattern": "^[^[*?\\]{}]+$"
47
+ },
48
+ "additionalProperties": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "string",
52
+ "pattern": "^[^[*?\\]{}]+$"
53
+ }
54
+ },
55
+ "description": "Per-server tool lists mapping literal MCP server names to arrays of literal pi-mcp-adapter tool selectors. Original and prefixed names can select the same tool; globs are not allowed. An omitted server allows all its tools; an empty list denies all its tools."
42
56
  },
43
57
  "defaultProvider": {
44
58
  "type": "string",
@@ -54,8 +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, there is no dependency on `pi-mcp-adapter`. |
58
- | `tools` | `string[]` | Whitelisted tool names or globs. When undeclared, tools are not narrowed and Pi's native tool set is kept. |
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`. |
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
60
  | `defaultProvider` | `string` | Default model provider (e.g. `"anthropic"`, `"openai"`). Effective only when declared together with `defaultModel`. |
60
61
  | `defaultModel` | `string` | Default model name (e.g. `"claude-sonnet-4-5"`). Effective only when declared together with `defaultProvider`. |
61
62
  | `defaultThinkingLevel` | `string` | Default thinking level; allowed values: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. Effective only when the model declaration holds. |
@@ -105,12 +106,20 @@ When helping the user configure a profile, check or consult the following locati
105
106
  3. **MCP servers**:
106
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`.
107
108
  - Reference identity: the server key names under the `mcpServers` object in those configuration files.
108
- - A profile declaring `mcps` must also ensure the `pi-mcp-adapter` extension is available.
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`.
109
110
  4. **Tools**:
110
- - Reference identity: tool names in Pi's live tool registry.
111
- - Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.), extension-contributed tools, and tools exposed by MCP servers (the proxy tool `mcp__<server>` and direct tools `<server>_<tool>`).
112
- - Globs supported (e.g. `"mcp__*"`, `"github_*"`).
113
- 5. **Narrowing boundary of project-level resources (important)**:
111
+ - Reference identity: non-MCP tool names in Pi's live tool registry.
112
+ - Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.) and extension-contributed tools.
113
+ - MCP-owned tools are **not** controlled by `tools`; legacy references such as `"mcp__*"` or `"github_*"` in `tools` must be migrated to `mcp_tools`.
114
+ 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.
118
+ - Omitting a server key preserves unrestricted access to that server's tools.
119
+ - An empty list (`[]`) denies all tools for that server while keeping the server enabled.
120
+ - A nonempty list (e.g. `["search", "get_issue"]`) allows only those tools across direct tools, gateway proxies, and scripts.
121
+ - Narrowing applies only to user-level MCP servers; project-level MCP servers cannot be narrowed by profiles.
122
+ 6. **Narrowing boundary of project-level resources (important)**:
114
123
  - A profile's resource selection (`skills`, `extensions`) **applies only to user-level resources** (the real agentDir and `~/.agents/skills`).
115
124
  - The visibility of project-level resources (project `.pi/skills`, project `.pi/extensions`, ancestor `.agents/skills`) is decided by Pi's project-trust determination: in a trusted project they are always visible under **any** profile; in an untrusted project they are never visible.
116
125
  - Therefore project-level resource visibility **does not narrow with profiles** — there is no need, and no guidance, to declare project-level resources in a profile.
@@ -14,8 +14,7 @@
14
14
  import path from "node:path";
15
15
 
16
16
  import { isRecord, readJsonFile } from "../json-file.ts";
17
- import { discoverAdapterServerNames } from "../mcp-config.ts";
18
- import { isAdapterExtension, MissingMcpAdapterError } from "../mcp-config.ts";
17
+ import { isAdapterExtension, loadMergedMcpServers, MissingMcpAdapterError } from "../mcp-config.ts";
19
18
  import { ProfileCatalog, type ResolvedProfile } from "../profile-catalog.ts";
20
19
  import { ActivationError, defaultPlan, resolveProfile, type ActivationPlan } from "../profile-resolver.ts";
21
20
  import { resolveProjectTrust } from "../project-trust.ts";
@@ -159,24 +158,38 @@ export async function resolveInitialProfile(
159
158
  return { plan, discovery, projectDir, projectTrusted, warnings };
160
159
  }
161
160
 
161
+ const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
162
+ const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
163
+ const needsMcp = Boolean(
164
+ profile.definition.mcps?.length ||
165
+ hasMcpTools ||
166
+ (options?.overlay?.disabledMcps?.length ?? 0) > 0,
167
+ );
168
+ const mcpDiscovery = needsMcp
169
+ ? await loadMergedMcpServers(context.agentDir, projectDir)
170
+ : undefined;
171
+
162
172
  const discovery = await discoverLauncherResources({ ...context, projectTrusted });
163
173
  const plan = await resolveProfile({
164
174
  profile,
165
175
  skills: discovery.skills,
166
176
  extensions: discovery.extensions,
167
177
  validateModel: (model) => checkDeclaredModel(context.agentDir, model),
168
- discoveredMcpServers: profile.definition.mcps?.length
169
- ? await discoverAdapterServerNames(context.agentDir, projectDir)
170
- : undefined,
178
+ discoveredMcpServers: mcpDiscovery ? Object.keys(mcpDiscovery.servers).sort() : undefined,
179
+ mcpDiscovery,
171
180
  overlay: options?.overlay,
172
181
  liveToolNames: options?.liveToolNames,
173
182
  });
174
183
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
175
184
  if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
176
- // Fail before spawn: without the adapter in the active extension set
177
- // nobody applies the allowlist, and the declared servers would either
178
- // silently do nothing or leak through unfiltered.
179
- throw new MissingMcpAdapterError(plan.profile);
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");
180
193
  }
181
194
  return { plan, discovery, projectDir, projectTrusted, warnings };
182
195
  }
package/src/mcp-config.ts CHANGED
@@ -43,9 +43,16 @@ export interface MergedMcpResult {
43
43
  /** Servers defined in a trusted project's own config (`.mcp.json`,
44
44
  * `.pi/mcp.json`). They are not the profile's to narrow. */
45
45
  projectServers: Set<string>;
46
+ /** Winning origin of each discovered server: "project" if defined or
47
+ * shadowed by project-level configuration, "user" otherwise. */
48
+ serverOwners: Record<string, "user" | "project">;
46
49
  baseConfig?: Record<string, unknown>;
47
50
  }
48
51
 
52
+ function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
53
+ Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
54
+ }
55
+
49
56
  export interface McpConfigSource {
50
57
  path: string;
51
58
  isShared: boolean;
@@ -93,6 +100,7 @@ export async function loadMergedMcpServers(
93
100
  const servers: Record<string, Record<string, unknown>> = {};
94
101
  const sharedServers = new Set<string>();
95
102
  const projectServers = new Set<string>();
103
+ const serverOwners: Record<string, "user" | "project"> = {};
96
104
  let baseConfig: Record<string, unknown> | undefined;
97
105
 
98
106
  for (const source of sources) {
@@ -116,21 +124,22 @@ export async function loadMergedMcpServers(
116
124
  throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
117
125
  }
118
126
  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.
130
+ if (name === "__proto__") continue;
119
131
  if (source.isShared) {
120
132
  sharedServers.add(name);
121
133
  }
122
- if (source.isProject === true) {
123
- projectServers.add(name);
124
- }
125
- if (isRecord(def)) {
126
- servers[name] = { ...(servers[name] ?? {}), ...def };
127
- } else {
128
- servers[name] = { ...(servers[name] ?? {}) };
129
- }
134
+ if (source.isProject === true) projectServers.add(name);
135
+ setOwnRecordValue(serverOwners, name, source.isProject === true ? "project" : "user");
136
+ const previous = Object.hasOwn(servers, name) ? servers[name] : undefined;
137
+ const merged = isRecord(def) ? { ...(previous ?? {}), ...def } : { ...(previous ?? {}) };
138
+ setOwnRecordValue(servers, name, merged);
130
139
  }
131
140
  }
132
141
 
133
- return { servers, sharedServers, projectServers, baseConfig };
142
+ return { servers, sharedServers, projectServers, serverOwners, baseConfig };
134
143
  }
135
144
 
136
145
  /** Server names the adapter would discover: standard global MCP configs,
@@ -146,10 +155,13 @@ export async function discoverAdapterServerNames(
146
155
  }
147
156
 
148
157
  export class MissingMcpAdapterError extends Error {
149
- constructor(profile: string) {
158
+ constructor(profile: string, reason: "mcps" | "mcp_tools" = "mcps") {
150
159
  super(
151
- `profile "${profile}" declares MCP servers but pi-mcp-adapter is not active. ` +
152
- `Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcps" declaration.`,
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.`,
153
165
  );
154
166
  this.name = "MissingMcpAdapterError";
155
167
  }
@@ -44,6 +44,8 @@ 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. */
48
+ mcp_tools?: Record<string, string[]>;
47
49
  defaultProvider?: string;
48
50
  defaultModel?: string;
49
51
  defaultThinkingLevel?: string;
@@ -89,6 +91,49 @@ function readOptionalString(value: unknown, field: string, profileName: string,
89
91
  return value;
90
92
  }
91
93
 
94
+ function isGlobPattern(value: string): boolean {
95
+ return (
96
+ value.includes("*") ||
97
+ value.includes("?") ||
98
+ value.includes("[") ||
99
+ value.includes("]") ||
100
+ value.includes("{") ||
101
+ value.includes("}")
102
+ );
103
+ }
104
+
105
+ function readMcpTools(
106
+ value: unknown,
107
+ profileName: string,
108
+ filePath?: string,
109
+ ): Record<string, string[]> | undefined {
110
+ if (value === undefined) return undefined;
111
+ const prefix = filePath ? `${filePath}: ` : "";
112
+ if (!isRecord(value)) {
113
+ throw new CatalogError(`${prefix}profile "${profileName}": "mcp_tools" must be an object of string arrays`);
114
+ }
115
+ const entries: Array<[string, string[]]> = [];
116
+ for (const [server, tools] of Object.entries(value)) {
117
+ if (isGlobPattern(server)) {
118
+ throw new CatalogError(
119
+ `${prefix}profile "${profileName}": "mcp_tools" server "${server}" is a glob pattern; literal MCP server names are required`,
120
+ );
121
+ }
122
+ if (!Array.isArray(tools) || tools.some((entry) => typeof entry !== "string")) {
123
+ throw new CatalogError(`${prefix}profile "${profileName}": "mcp_tools" must be an object of string arrays`);
124
+ }
125
+ for (const tool of tools) {
126
+ if (isGlobPattern(tool)) {
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`,
129
+ );
130
+ }
131
+ }
132
+ entries.push([server, [...tools]]);
133
+ }
134
+ return Object.fromEntries(entries);
135
+ }
136
+
92
137
  /** Parses one raw profile definition; the single read-time validator so
93
138
  * catalog files and any external writer stay loadable. */
94
139
  export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
@@ -105,6 +150,8 @@ export function parseProfileDefinition(name: string, raw: unknown, filePath?: st
105
150
  const entries = readStringArray(raw[field], field, name, filePath);
106
151
  if (entries !== undefined) definition[field] = entries;
107
152
  }
153
+ const mcpTools = readMcpTools(raw.mcp_tools, name, filePath);
154
+ if (mcpTools !== undefined) definition.mcp_tools = mcpTools;
108
155
  const defaultProvider = readOptionalString(raw.defaultProvider, "defaultProvider", name, filePath);
109
156
  if (defaultProvider !== undefined) definition.defaultProvider = defaultProvider;
110
157
  const defaultModel = readOptionalString(raw.defaultModel, "defaultModel", name, filePath);
@@ -27,6 +27,8 @@
27
27
  import { minimatch } from "minimatch";
28
28
 
29
29
  import type { DiscoveredExtensions } from "./extension-discovery.ts";
30
+ import { isRecord } from "./json-file.ts";
31
+ import { isAdapterExtension, type MergedMcpResult } from "./mcp-config.ts";
30
32
  import type { ProfileDefinition, ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
31
33
 
32
34
  /** Extracts a ProfileModel from flat definition keys, if declared. */
@@ -85,6 +87,10 @@ export interface ActivationPlan {
85
87
  * surfaced in `/profile status`; undefined when the profile declares
86
88
  * no `mcps` (no restriction). */
87
89
  mcps?: string[];
90
+ /** Per-server MCP tool policy: literal adapter selectors per server. */
91
+ mcpTools?: Record<string, string[]>;
92
+ /** Prepared in-memory instance mcp.json config; never written to pi-profile.json */
93
+ instanceMcpConfig?: Record<string, unknown>;
88
94
  /** Glob references (skills/extensions/MCP) that matched nothing this
89
95
  * resolution — surfaced as warnings so zero-match typos are never silent.
90
96
  * Tool globs are excluded: extension-contributed tools are unknowable
@@ -111,6 +117,11 @@ export interface ResolveInput {
111
117
  * fails rather than passing references through unchecked.
112
118
  */
113
119
  discoveredMcpServers?: string[];
120
+ /**
121
+ * Merged MCP configuration result. Carries server definitions, ownership,
122
+ * and base settings.
123
+ */
124
+ mcpDiscovery?: MergedMcpResult;
114
125
  /**
115
126
  * The runtime overlay (ticket 06): temporary narrowing applied on top of
116
127
  * the profile definition at every resolution. Overlay references must
@@ -213,6 +224,10 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
213
224
  const definition: ProfileDefinition = profile.definition;
214
225
  const unmatched: string[] = [];
215
226
 
227
+ if (input.mcpDiscovery !== undefined && input.discoveredMcpServers === undefined) {
228
+ input.discoveredMcpServers = Object.keys(input.mcpDiscovery.servers).sort();
229
+ }
230
+
216
231
  let mcps: string[] | undefined;
217
232
  if (definition.mcps !== undefined && definition.mcps.length > 0) {
218
233
  if (input.discoveredMcpServers === undefined) {
@@ -286,6 +301,67 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
286
301
  }
287
302
  }
288
303
 
304
+ const mcpToolsDef = (definition as ProfileDefinition & { mcp_tools?: Record<string, string[]> }).mcp_tools;
305
+ const mcpToolKeys = mcpToolsDef !== undefined ? Object.keys(mcpToolsDef) : [];
306
+
307
+ let mcpTools: Record<string, string[]> | undefined;
308
+ 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
+ if (input.mcpDiscovery === undefined) {
317
+ throw new ActivationError(
318
+ `profile "${profile.name}" declares MCP tools but no adapter server discovery is available`,
319
+ );
320
+ }
321
+
322
+ const mcpDiscovery = input.mcpDiscovery;
323
+ const usableCandidates = Object.keys(mcpDiscovery.servers)
324
+ .filter((s) => {
325
+ const isUser =
326
+ Object.hasOwn(mcpDiscovery.serverOwners, s) &&
327
+ mcpDiscovery.serverOwners[s] === "user" &&
328
+ !mcpDiscovery.projectServers.has(s);
329
+ const isEnabled = mcpDiscovery.servers[s]?.disabled !== true;
330
+ const isAllowedByMcps = mcps === undefined || mcps.includes(s);
331
+ return isUser && isEnabled && isAllowedByMcps;
332
+ })
333
+ .sort();
334
+ const candidateMsg = usableCandidates.length > 0 ? ` (usable candidates: ${usableCandidates.join(", ")})` : "";
335
+
336
+ for (const serverKey of mcpToolKeys) {
337
+ if (!Object.hasOwn(mcpDiscovery.servers, serverKey)) {
338
+ throw new ActivationError(`profile "${profile.name}": unknown MCP server "${serverKey}"${candidateMsg}`);
339
+ }
340
+ const isProject =
341
+ mcpDiscovery.serverOwners[serverKey] === "project" || mcpDiscovery.projectServers.has(serverKey);
342
+ if (isProject) {
343
+ throw new ActivationError(
344
+ `profile "${profile.name}": cannot narrow project-level MCP server "${serverKey}" (project-level servers are outside profile narrowing)`,
345
+ );
346
+ }
347
+ const isDisabled =
348
+ mcpDiscovery.servers[serverKey]?.disabled === true ||
349
+ (mcps !== undefined && !mcps.includes(serverKey));
350
+ if (isDisabled) {
351
+ throw new ActivationError(`profile "${profile.name}": MCP server "${serverKey}" is disabled${candidateMsg}`);
352
+ }
353
+ }
354
+
355
+ mcpTools = Object.fromEntries(
356
+ Object.entries(mcpToolsDef ?? {}).map(([server, selectors]) => [server, [...selectors]]),
357
+ );
358
+ }
359
+
360
+ let instanceMcpConfig: Record<string, unknown> | undefined;
361
+ if (input.mcpDiscovery !== undefined && (mcps !== undefined || mcpToolKeys.length > 0)) {
362
+ instanceMcpConfig = buildInstanceMcpConfig(profile.name, input.mcpDiscovery, mcps, mcpTools);
363
+ }
364
+
289
365
  let tools: string[] | undefined;
290
366
  if (toolReferences !== undefined) {
291
367
  tools = expandReferences(toolReferences, BUILTIN_TOOL_NAMES, (name) => name, "tool", {
@@ -357,6 +433,120 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
357
433
  ...(model !== undefined ? { model } : {}),
358
434
  ...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
359
435
  ...(mcps !== undefined ? { mcps } : {}),
436
+ ...(mcpTools !== undefined ? { mcpTools } : {}),
437
+ ...(instanceMcpConfig !== undefined ? { instanceMcpConfig } : {}),
360
438
  ...(unmatched.length > 0 ? { unmatched } : {}),
361
439
  };
362
440
  }
441
+
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
+ function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
469
+ Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
470
+ }
471
+
472
+ export function buildInstanceMcpConfig(
473
+ profileName: string,
474
+ mcpDiscovery: MergedMcpResult,
475
+ mcps?: string[],
476
+ mcpTools?: Record<string, string[]>,
477
+ ): Record<string, unknown> {
478
+ const filteredServers: Record<string, Record<string, unknown>> = {};
479
+ const userServers = Object.keys(mcpDiscovery.servers).filter(
480
+ (s) =>
481
+ Object.hasOwn(mcpDiscovery.serverOwners, s) &&
482
+ mcpDiscovery.serverOwners[s] === "user" &&
483
+ !mcpDiscovery.projectServers.has(s),
484
+ );
485
+
486
+ const serversToInclude = mcps !== undefined ? mcps : userServers;
487
+ for (const serverName of serversToInclude) {
488
+ const originalDef = Object.hasOwn(mcpDiscovery.servers, serverName)
489
+ ? mcpDiscovery.servers[serverName]
490
+ : undefined;
491
+ if (originalDef !== undefined) {
492
+ const def = { ...originalDef };
493
+ if (mcps !== undefined) {
494
+ delete def.disabled;
495
+ }
496
+
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
+ }
514
+
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;
518
+
519
+ const safeTools = computeSafeMcpToolIntersection(
520
+ profileName,
521
+ serverName,
522
+ requested,
523
+ existingIncludes,
524
+ );
525
+
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
+ }
534
+ }
535
+
536
+ setOwnRecordValue(filteredServers, serverName, def);
537
+ }
538
+ }
539
+
540
+ 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
+ }
546
+ }
547
+ }
548
+
549
+ return isRecord(mcpDiscovery.baseConfig)
550
+ ? { ...mcpDiscovery.baseConfig, mcpServers: filteredServers }
551
+ : { mcpServers: filteredServers };
552
+ }
@@ -45,7 +45,7 @@ import { mkdir, mkdtemp, lstat, readdir, readFile, readlink, rm, stat, symlink,
45
45
  import { homedir } from "node:os";
46
46
  import path from "node:path";
47
47
 
48
- import type { ActivationPlan } from "./profile-resolver.ts";
48
+ import { buildInstanceMcpConfig, type ActivationPlan } from "./profile-resolver.ts";
49
49
  import { getInstancesRootDir } from "./workspace.ts";
50
50
  import { isRecord } from "./json-file.ts";
51
51
  import { loadMergedMcpServers } from "./mcp-config.ts";
@@ -403,6 +403,7 @@ export async function writeRuntimeFiles(
403
403
  ...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
404
404
  ...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
405
405
  ...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
406
+ ...(plan.mcpTools !== undefined ? { mcpTools: plan.mcpTools } : {}),
406
407
  // The resolved sets feed /profile status (absolute paths) and the
407
408
  // glob-delta diff against the previous activation.
408
409
  resolved: {
@@ -429,49 +430,36 @@ export async function writeRuntimeFiles(
429
430
  await symlink(path.join(options.agentDir, "trust.json"), trustLink);
430
431
  }
431
432
 
432
- // MCP Servers generation (Ticket 04)
433
+ // MCP Servers generation (Ticket 04 & Separate MCP tool filtering)
433
434
  const mcpTarget = path.join(options.agentDir, "mcp.json");
434
435
  const mcpInstancePath = path.join(runtimeDir, "mcp.json");
435
- if (plan.mcps === undefined) {
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
+ ) {
436
443
  // No restrictions, symlink
437
444
  if (await exists(mcpTarget)) {
438
445
  try { await rm(mcpInstancePath); } catch {}
439
446
  await symlink(mcpTarget, mcpInstancePath);
440
447
  }
441
448
  } else {
442
- // Filter MCP servers
449
+ // Filter MCP servers (fallback when instanceMcpConfig is not pre-populated)
443
450
  try { await rm(mcpInstancePath); } catch {}
444
- const { servers, sharedServers, projectServers, baseConfig } = await loadMergedMcpServers(
451
+ const discovery = await loadMergedMcpServers(
445
452
  options.agentDir,
446
453
  options.projectDir,
447
454
  options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
448
455
  );
449
-
450
- const allowedSet = new Set(plan.mcps);
451
- const filteredServers: Record<string, unknown> = {};
452
-
453
- for (const serverName of plan.mcps) {
454
- if (servers[serverName] !== undefined) {
455
- const def = { ...servers[serverName] };
456
- delete def.disabled;
457
- filteredServers[serverName] = def;
458
- }
459
- }
460
-
461
- for (const sharedName of sharedServers) {
462
- if (!allowedSet.has(sharedName)) {
463
- // Project-level servers are not the profile's to narrow (the same
464
- // boundary as project skills and extensions).
465
- if (projectServers.has(sharedName)) continue;
466
- filteredServers[sharedName] = { disabled: true };
467
- }
468
- }
469
-
470
- const outputConfig: Record<string, unknown> = isRecord(baseConfig)
471
- ? { ...baseConfig, mcpServers: filteredServers }
472
- : { mcpServers: filteredServers };
473
-
474
- await writeFile(mcpInstancePath, JSON.stringify(outputConfig, null, 2));
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));
475
463
  }
476
464
 
477
465
  // Instructions generation (Ticket 04)
@@ -8,13 +8,12 @@
8
8
  * surface so unit tests never need a real Pi.
9
9
  *
10
10
  * Steps:
11
- * 1. tools: re-expand the profile's raw tool references against Pi's LIVE
12
- * tool registry (including extension- and MCP-provided tools) and
13
- * call setActiveTools. This is the CURRENT strict-allowlist enforcement
14
- * ensuring non-builtin tools obey profile restrictions; settings
15
- * `defaultTools` provides only the boot baseline for built-ins.
16
- * Literals that no tool provides are dropped with a warning — Pi
17
- * silently ignores unknown names, so the warning is the only signal.
11
+ * 1. tools: re-expand the profile's raw references against Pi's LIVE
12
+ * non-MCP tool registry and call setActiveTools while retaining
13
+ * adapter-owned registrations. Settings `defaultTools` provides only
14
+ * the boot baseline for built-ins. Literals that no Pi tool provides
15
+ * are dropped with a warning — Pi silently ignores unknown names, so
16
+ * the warning is the only signal.
18
17
  * When the plan carries overlay disabled tool entries, the active set
19
18
  * is the base expansion — the profile references, or the whole live
20
19
  * registry when the profile declares none — minus the entries' live
@@ -31,7 +30,7 @@
31
30
  * established.
32
31
  */
33
32
 
34
- import { writeFile } from "node:fs/promises";
33
+ import { readFile, writeFile } from "node:fs/promises";
35
34
  import path from "node:path";
36
35
 
37
36
  import { isRecord, readJsonFile } from "../json-file.ts";
@@ -49,12 +48,13 @@ export interface LaunchPlanFile {
49
48
  * from the base expansion at session start. */
50
49
  disabledTools?: string[];
51
50
  mcps?: string[];
51
+ mcpTools?: Record<string, string[]>;
52
52
  switchedFrom?: string;
53
53
  persistSelection?: boolean;
54
54
  clearOverlay?: boolean;
55
55
  resolved?: {
56
56
  skills: Array<{ name: string; filePath: string }>;
57
- extensions: Array<{ id: string; entry: string }>;
57
+ extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
58
58
  };
59
59
  /** Glob references that matched nothing at resolution (ADR-0009). */
60
60
  unmatched?: string[];
@@ -68,7 +68,10 @@ export interface LaunchPlanFile {
68
68
 
69
69
  /** The narrow slice of ExtensionAPI/Context the application needs. */
70
70
  export interface PlanApplicationSurface {
71
- getAllTools(): Array<{ name: string }>;
71
+ getAllTools(): Array<{
72
+ name: string;
73
+ sourceInfo?: { path?: string; source?: string };
74
+ }>;
72
75
  setActiveTools(names: string[]): void;
73
76
  notify?(message: string, level: "info" | "warning" | "error"): void;
74
77
  }
@@ -87,6 +90,70 @@ export async function readLaunchPlanFile(runtimeDir: string): Promise<LaunchPlan
87
90
  return result.value as unknown as LaunchPlanFile;
88
91
  }
89
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 {
143
+ const info = tool.sourceInfo;
144
+ 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;
155
+ }
156
+
90
157
  /** Applies the plan carried by the runtime dir's pi-profile.json. */
91
158
  export async function applyLaunchPlan(input: {
92
159
  runtimeDir: string;
@@ -103,25 +170,53 @@ export async function applyLaunchPlan(input: {
103
170
  const warnings: string[] = [];
104
171
 
105
172
  // --- tools ---
106
- if (plan.toolReferences !== undefined || plan.disabledTools !== undefined) {
107
- const liveNames = surface.getAllTools().map((tool) => tool.name);
173
+ const hasOverlayDisables = plan.disabledTools !== undefined && plan.disabledTools.length > 0;
174
+ if (plan.toolReferences !== undefined || hasOverlayDisables) {
175
+ const allTools = surface.getAllTools();
176
+ const attribution = await getAdapterAttribution(plan);
177
+ const mcpToolNames: string[] = [];
178
+ const nonMcpToolNames: string[] = [];
179
+
180
+ for (const tool of allTools) {
181
+ if (isMcpOwnedTool(tool, attribution)) {
182
+ mcpToolNames.push(tool.name);
183
+ } else {
184
+ nonMcpToolNames.push(tool.name);
185
+ }
186
+ }
187
+
108
188
  let active: string[];
109
189
  if (plan.toolReferences !== undefined) {
110
- const { expanded, droppedLiterals } = expandToolReferences(plan.toolReferences, liveNames);
190
+ const { expanded, droppedLiterals, legacyMcpReferences } = expandToolReferences(
191
+ plan.toolReferences,
192
+ nonMcpToolNames,
193
+ mcpToolNames,
194
+ );
111
195
  if (droppedLiterals.length > 0) {
112
196
  warnings.push(
113
197
  `profile "${plan.profile}": tools ${droppedLiterals.map((name) => JSON.stringify(name)).join(", ")} match nothing in Pi's live registry`,
114
198
  );
115
199
  }
116
- active = expanded;
200
+ if (legacyMcpReferences.length > 0) {
201
+ for (const ref of legacyMcpReferences) {
202
+ warnings.push(
203
+ `profile "${plan.profile}": tool reference ${JSON.stringify(ref)} matched only MCP tools; migrate MCP tool configuration to "mcp_tools"`,
204
+ );
205
+ }
206
+ }
207
+ // Retain MCP-owned tools in the live registry alongside selected Pi tools
208
+ active = [...expanded, ...mcpToolNames];
117
209
  } else {
118
210
  // No declared tools: the base is the whole live registry.
119
- active = [...liveNames];
211
+ active = allTools.map((tool) => tool.name);
120
212
  }
121
- if (plan.disabledTools !== undefined && plan.disabledTools.length > 0) {
213
+
214
+ if (hasOverlayDisables) {
215
+ const allLiveNames = allTools.map((tool) => tool.name);
122
216
  const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
123
- plan.disabledTools,
124
- liveNames,
217
+ plan.disabledTools!,
218
+ allLiveNames,
219
+ mcpToolNames,
125
220
  );
126
221
  if (vanishedEntries.length > 0) {
127
222
  warnings.push(
@@ -26,6 +26,12 @@ export interface StatusConflict {
26
26
  winnerPath: string;
27
27
  }
28
28
 
29
+ export interface McpServerToolStatus {
30
+ server: string;
31
+ policy: "unrestricted" | "restricted" | "none";
32
+ tools?: string[];
33
+ }
34
+
29
35
  export interface StatusReport {
30
36
  profile: string;
31
37
  source: string;
@@ -34,6 +40,7 @@ export interface StatusReport {
34
40
  extensions: Array<{ id: string; entry: string; origin?: string }>;
35
41
  tools?: string[];
36
42
  mcp: { enabled: string[]; disabled: string[]; missing: string[] };
43
+ mcpTools?: McpServerToolStatus[];
37
44
  /** Glob delta versus the previous activation (prefixed names). */
38
45
  delta?: { added: string[]; removed: string[] };
39
46
  /** Glob references that matched nothing at resolution (ADR-0009). */
@@ -48,7 +55,7 @@ interface RegisteredCommand {
48
55
 
49
56
  interface RegisteredTool {
50
57
  name: string;
51
- sourceInfo?: { path: string; source: string };
58
+ sourceInfo?: { path?: string; source?: string };
52
59
  }
53
60
 
54
61
  function currentNames(plan: LaunchPlanFile): string[] {
@@ -65,18 +72,49 @@ export function buildStatusReport(input: {
65
72
  plan: LaunchPlanFile;
66
73
  overlay?: RuntimeOverlay;
67
74
  discoveredMcpServers: string[];
75
+ disabledMcpServers: string[];
68
76
  commands: RegisteredCommand[];
69
77
  tools: RegisteredTool[];
70
78
  }): StatusReport {
71
79
  const { plan } = input;
72
80
 
73
- const enabled = plan.mcps ?? [];
74
81
  const discovered = new Set(input.discoveredMcpServers);
75
- const mcp = {
76
- enabled,
77
- disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
78
- missing: enabled.filter((name) => !discovered.has(name)),
79
- };
82
+ let enabled: string[];
83
+ let disabled: string[];
84
+ let missing: string[];
85
+
86
+ 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
+ missing = [];
91
+ } else {
92
+ enabled = plan.mcps;
93
+ disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));
94
+ missing = enabled.filter((name) => !discovered.has(name));
95
+ }
96
+
97
+ const mcp = { enabled, disabled, missing };
98
+
99
+ let mcpTools: McpServerToolStatus[] | undefined;
100
+ const policyMap = plan.mcpTools;
101
+ if (policyMap !== undefined || enabled.length > 0) {
102
+ mcpTools = [];
103
+ const allServers = Array.from(new Set([...enabled, ...Object.keys(policyMap ?? {})]));
104
+ for (const server of allServers) {
105
+ const hasPolicy = policyMap !== undefined && Object.prototype.hasOwnProperty.call(policyMap, server);
106
+ if (!hasPolicy) {
107
+ mcpTools.push({ server, policy: "unrestricted" });
108
+ } else {
109
+ const declaredTools = policyMap![server] ?? [];
110
+ if (declaredTools.length === 0) {
111
+ mcpTools.push({ server, policy: "none", tools: [] });
112
+ } else {
113
+ mcpTools.push({ server, policy: "restricted", tools: declaredTools });
114
+ }
115
+ }
116
+ }
117
+ }
80
118
 
81
119
  let delta: StatusReport["delta"];
82
120
  if (plan.previousResolved !== undefined) {
@@ -117,9 +155,15 @@ export function buildStatusReport(input: {
117
155
  const winner = input.tools.find((entry) => entry.name === toolName);
118
156
  const info = winner?.sourceInfo;
119
157
  if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
120
- const expected = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
158
+ const expected =
159
+ info.source === "builtin" ||
160
+ (typeof info.path === "string" && extensionDirs.some((dir) => info.path!.startsWith(dir)));
121
161
  if (!expected) {
122
- conflicts.push({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
162
+ conflicts.push({
163
+ name: `tool:${toolName}`,
164
+ expectedPath: "builtin or selected extension",
165
+ winnerPath: info.path ?? "unknown",
166
+ });
123
167
  }
124
168
  }
125
169
 
@@ -131,6 +175,7 @@ export function buildStatusReport(input: {
131
175
  extensions: plan.resolved?.extensions ?? [],
132
176
  ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
133
177
  mcp,
178
+ ...(mcpTools !== undefined ? { mcpTools } : {}),
134
179
  ...(delta !== undefined ? { delta } : {}),
135
180
  ...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
136
181
  conflicts,
@@ -168,6 +213,18 @@ export function formatStatusMarkdown(report: StatusReport): string {
168
213
  lines.push(
169
214
  `mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
170
215
  );
216
+ if (report.mcpTools !== undefined && report.mcpTools.length > 0) {
217
+ lines.push("mcp tools:");
218
+ for (const item of report.mcpTools) {
219
+ if (item.policy === "unrestricted") {
220
+ lines.push(` ${item.server}: unrestricted`);
221
+ } else if (item.policy === "none") {
222
+ lines.push(` ${item.server}: no enabled MCP tools`);
223
+ } else {
224
+ lines.push(` ${item.server}: [${(item.tools ?? []).join(", ")}]`);
225
+ }
226
+ }
227
+ }
171
228
  if (report.delta !== undefined) {
172
229
  lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
173
230
  }
@@ -15,26 +15,48 @@ export interface ToolExpansion {
15
15
  expanded: string[];
16
16
  /** Literal references no live tool provides. */
17
17
  droppedLiterals: string[];
18
+ /** References that matched only MCP-owned tools in the live registry. */
19
+ legacyMcpReferences: string[];
18
20
  }
19
21
 
20
- export function expandToolReferences(references: string[], liveToolNames: string[]): ToolExpansion {
21
- const live = new Set(liveToolNames);
22
+ export function expandToolReferences(
23
+ references: string[],
24
+ liveToolNames: string[],
25
+ mcpToolNames: string[] = [],
26
+ ): ToolExpansion {
27
+ const liveNonMcp = new Set(liveToolNames);
28
+ const liveMcp = new Set(mcpToolNames);
22
29
  const expanded = new Set<string>();
23
30
  const droppedLiterals: string[] = [];
31
+ const legacyMcpReferences: string[] = [];
32
+
24
33
  for (const reference of references) {
25
34
  if (reference.includes("*") || reference.includes("?")) {
35
+ let matchedNonMcp = false;
26
36
  for (const name of liveToolNames) {
27
37
  if (minimatch(name, reference)) {
28
38
  expanded.add(name);
39
+ matchedNonMcp = true;
40
+ }
41
+ }
42
+ if (!matchedNonMcp && mcpToolNames.length > 0) {
43
+ for (const name of mcpToolNames) {
44
+ if (minimatch(name, reference)) {
45
+ legacyMcpReferences.push(reference);
46
+ break;
47
+ }
29
48
  }
30
49
  }
31
50
  continue;
32
51
  }
33
- if (live.has(reference)) {
52
+
53
+ if (liveNonMcp.has(reference)) {
34
54
  expanded.add(reference);
55
+ } else if (liveMcp.has(reference)) {
56
+ legacyMcpReferences.push(reference);
35
57
  } else {
36
58
  droppedLiterals.push(reference);
37
59
  }
38
60
  }
39
- return { expanded: [...expanded], droppedLiterals };
61
+ return { expanded: [...expanded], droppedLiterals, legacyMcpReferences };
40
62
  }