pi-profile-switch 0.10.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,20 +95,15 @@ 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
 
98
105
  The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
99
106
 
100
- ### Migrating from earlier versions
101
-
102
- If you used an earlier version that stored all profiles in a single `profiles.json` (`schemaVersion: 1`), migrate manually by creating a file for each profile under the `profiles/` directory:
103
-
104
- 1. Create directory `~/.pi-profile-switch/profiles/` (or `<project>/.pi/profiles/`).
105
- 2. For each key `<name>` in your old `profiles.json`'s `profiles` object, save its value directly as `<name>.json`.
106
- 3. Drop the outer `schemaVersion` and `profiles` envelope.
107
-
108
107
  ## Commands
109
108
 
110
109
  The `/profile` command family manages everything in-session:
@@ -119,13 +118,6 @@ The `/profile` command family manages everything in-session:
119
118
 
120
119
  All forms work in every mode, including non-interactive ones (`--mode rpc|print|json`); the bare selector degrades to the profile list where no interactive UI exists. The overlay is a runtime-only narrowing: it is never written to a catalog file and never survives a restart. Tools follow the same disable/enable model as the other resource kinds: a tool `disable` entry narrows the profile's resolved tool references — or the runtime's full available tool set when the profile declares no `tools`.
121
120
 
122
- Removed subcommands and their replacements:
123
-
124
- | Removed | Replacement |
125
- | --- | --- |
126
- | `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | edit the catalog JSON files directly, or configure profiles conversationally with the [`profile-config`](skills/profile-config/SKILL.md) skill |
127
- | the `tools` replace-form of `/profile overlay` | `/profile overlay disable tool <name-or-glob>` (disable the complement, with globs) or declare the fixed set in the profile's `tools` field |
128
-
129
121
  ## Docs
130
122
 
131
123
  - [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md)
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,20 +95,15 @@ 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
 
98
105
  [`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
99
106
 
100
- ### 从旧版本迁移
101
-
102
- 如果你之前使用了把全部 profile 存在单个 `profiles.json`(含 `schemaVersion: 1`)的旧版本,请手动把每个 profile 拆分到 `profiles/` 目录:
103
-
104
- 1. 创建 `~/.pi-profile-switch/profiles/`(或 `<项目>/.pi/profiles/`)目录。
105
- 2. 将旧 `profiles.json` 中 `profiles` 下的每个 `<name>` 键值提取为独立的 `<name>.json` 文件。
106
- 3. 去除外层的 `schemaVersion` 与 `profiles` 信封,文件顶层即为裸 profile 定义。
107
-
108
107
  ## 命令
109
108
 
110
109
  `/profile` 命令族完成所有会话内操作:
@@ -119,13 +118,6 @@ pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** pr
119
118
 
120
119
  所有命令在非交互模式(`--mode rpc|print|json`)下同样生效;裸 `/profile` 在无交互界面时降级为 profile 列表。overlay 是会话级收窄:绝不写入 catalog 文件,也不会跨重启保留。tool 与其他资源类别使用相同的 disable/enable 模型:tool `disable` 条目收窄 profile 解析出的工具引用;profile 未声明 `tools` 时,则收窄运行时的全部可用工具集。
121
120
 
122
- 已移除子命令及其替代:
123
-
124
- | 移除 | 替代 |
125
- | --- | --- |
126
- | `/profile create` / `/profile edit` / `/profile delete` / `/profile duplicate` | 直接编辑 catalog JSON 文件,或通过 [`profile-config`](skills/profile-config/SKILL.md) skill 对话式配置 |
127
- | `/profile overlay` 的 `tools` 替换式 | `/profile overlay disable tool <name-or-glob>`(用 glob 禁用其余工具),或在 profile 的 `tools` 字段中声明固定集合 |
128
-
129
121
  ## 文档
130
122
 
131
123
  - [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
@@ -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",
@@ -1,16 +1,20 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
 
7
+ import { isRecord } from "../../src/json-file.ts";
5
8
  import { readTrustInputs } from "../../src/launcher/initial-profile.ts";
6
- import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
9
+ import { loadMergedMcpServers } from "../../src/mcp-config.ts";
7
10
  import { RuntimeStateStore } from "../../src/runtime-state-store.ts";
11
+ import { runStartupNotifications, type NoticeSurface } from "../../src/startup-notifier.ts";
8
12
  import { applyLaunchPlan, readLaunchPlanFile } from "../../src/switching/apply-plan.ts";
9
13
  import { OVERLAY_USAGE, applyOverlayMutation, clearOverlay, parseOverlayArgs } from "../../src/switching/overlay.ts";
10
14
  import { formatProfileList, listProfiles } from "../../src/switching/list-profiles.ts";
11
15
  import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
12
16
  import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
13
- import { getGlobalStateDir } from "../../src/workspace.ts";
17
+ import { getGlobalStateDir, getProfileSwitchDir } from "../../src/workspace.ts";
14
18
 
15
19
  /**
16
20
  * pi-profile extension entry.
@@ -38,6 +42,59 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
38
42
  * exclusively through `session_start` — nothing stale survives.
39
43
  */
40
44
 
45
+ /** The bundled package's own metadata: the notifier compares the RUNNING
46
+ * package version (never Pi's version or a repository checkout). */
47
+ const OWN_PACKAGE_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
48
+
49
+ async function readOwnVersion(): Promise<string | undefined> {
50
+ try {
51
+ const raw: unknown = JSON.parse(await readFile(OWN_PACKAGE_JSON, "utf8"));
52
+ if (isRecord(raw) && typeof raw.version === "string") return raw.version;
53
+ } catch {
54
+ // Unresolvable package metadata: startup notices are skipped quietly.
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /** Notice presentation surface: the TUI notification channel when available,
60
+ * stderr in every other mode (never stdout, never the agent's prompts). */
61
+ function createNoticeSurface(ctx: unknown): NoticeSurface {
62
+ const context = ctx as {
63
+ hasUI?: boolean;
64
+ mode?: string;
65
+ ui?: { notify?: (message: string, level: "info" | "warning" | "error") => void };
66
+ };
67
+ if (context.hasUI === true && context.mode === "tui" && typeof context.ui?.notify === "function") {
68
+ return { display: (message, level) => context.ui!.notify!(message, level) };
69
+ }
70
+ return {
71
+ display: (message) => {
72
+ process.stderr.write(`${message}\n`);
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Startup notices run once per Pi process on the initial session_start,
78
+ * without awaiting remote IO, in an isolated failure domain: a notifier
79
+ * problem can never block profile activation, change Pi's exit code, or
80
+ * reach the activation/switch error path. Pi re-executes this module on
81
+ * reload, and reload/new/resume/fork never pass reason "startup", so no
82
+ * second check is possible within one process. */
83
+ async function startStartupNotices(surface: NoticeSurface): Promise<void> {
84
+ try {
85
+ const version = await readOwnVersion();
86
+ if (version === undefined) return;
87
+ await runStartupNotifications({
88
+ installedVersion: version,
89
+ workspaceDir: getProfileSwitchDir(),
90
+ offline: process.env.PI_OFFLINE === "1",
91
+ surface,
92
+ });
93
+ } catch {
94
+ // Best-effort by contract: swallow everything the notifier missed.
95
+ }
96
+ }
97
+
41
98
  function setProfileStatus(ui: unknown, profile: string | undefined): void {
42
99
  if (profile && typeof (ui as { setStatus?: (k: string, v: string) => void })?.setStatus === "function") {
43
100
  (ui as { setStatus: (k: string, v: string) => void }).setStatus("profile", `profile: ${profile}`);
@@ -51,6 +108,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
51
108
  let pendingSummary: string | undefined;
52
109
 
53
110
  pi.on("session_start", async (event, ctx) => {
111
+ // Capture the notice surface synchronously: pi may replace the session
112
+ // right after this handler (one-shot modes), after which a captured
113
+ // ctx throws on access. The surface freezes the channel now.
114
+ const noticeSurface = createNoticeSurface(ctx);
54
115
  const plan = await readLaunchPlanFile(runtimeDir);
55
116
  setProfileStatus(ctx.ui, plan?.profile);
56
117
  const result = await applyLaunchPlan({
@@ -64,6 +125,9 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
64
125
  },
65
126
  });
66
127
  pendingSummary = result.summary;
128
+ if (event.reason === "startup") {
129
+ void startStartupNotices(noticeSurface);
130
+ }
67
131
  });
68
132
 
69
133
  pi.on("before_agent_start", async (event) => {
@@ -157,13 +221,19 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
157
221
  const { projectTrusted } = await readTrustInputs({ agentDir: plan.agentDir, cwd: ctx.cwd });
158
222
  const stateDir = plan.source === "project" ? path.join(ctx.cwd, ".pi") : getGlobalStateDir(plan.agentDir);
159
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
+ );
160
232
  const report = buildStatusReport({
161
233
  plan,
162
234
  overlay: state.overlay,
163
- discoveredMcpServers: await discoverAdapterServerNames(
164
- plan.agentDir,
165
- projectTrusted ? ctx.cwd : undefined,
166
- ),
235
+ discoveredMcpServers,
236
+ disabledMcpServers,
167
237
  commands: pi.getCommands(),
168
238
  tools: pi.getAllTools(),
169
239
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-profile-switch",
3
- "version": "0.10.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);