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 +14 -7
- package/README.zh-CN.md +14 -7
- package/examples/example.json +8 -4
- package/extensions/pi-profile/index.ts +11 -5
- package/package.json +2 -1
- package/schemas/profiles.schema.json +15 -1
- package/skills/profile-config/SKILL.md +16 -7
- package/src/launcher/initial-profile.ts +22 -9
- package/src/mcp-config.ts +24 -12
- package/src/profile-catalog.ts +47 -0
- package/src/profile-resolver.ts +190 -0
- package/src/settings-generator.ts +19 -31
- package/src/switching/apply-plan.ts +113 -18
- package/src/switching/status.ts +66 -9
- package/src/switching/tool-references.ts +26 -4
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
package/examples/example.json
CHANGED
|
@@ -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 {
|
|
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
|
|
228
|
-
|
|
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.
|
|
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": "
|
|
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,
|
|
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
|
|
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.)
|
|
112
|
-
-
|
|
113
|
-
5. **
|
|
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 {
|
|
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:
|
|
169
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
152
|
-
`
|
|
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
|
}
|
package/src/profile-catalog.ts
CHANGED
|
@@ -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);
|
package/src/profile-resolver.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
12
|
-
* tool registry
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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<{
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
211
|
+
active = allTools.map((tool) => tool.name);
|
|
120
212
|
}
|
|
121
|
-
|
|
213
|
+
|
|
214
|
+
if (hasOverlayDisables) {
|
|
215
|
+
const allLiveNames = allTools.map((tool) => tool.name);
|
|
122
216
|
const { expanded: disabled, droppedLiterals: vanishedEntries } = expandToolReferences(
|
|
123
|
-
plan.disabledTools
|
|
124
|
-
|
|
217
|
+
plan.disabledTools!,
|
|
218
|
+
allLiveNames,
|
|
219
|
+
mcpToolNames,
|
|
125
220
|
);
|
|
126
221
|
if (vanishedEntries.length > 0) {
|
|
127
222
|
warnings.push(
|
package/src/switching/status.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
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({
|
|
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(
|
|
21
|
-
|
|
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
|
-
|
|
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
|
}
|