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