pi-profile-switch 0.12.2 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/bin/pi-profile.ts +3 -3
- package/examples/example.json +2 -6
- package/extensions/pi-profile/index.ts +1 -1
- package/package.json +1 -2
- package/skills/profile-config/SKILL.md +7 -10
- package/src/extension-discovery.ts +1 -1
- package/src/launcher/initial-profile.ts +4 -21
- package/src/mcp-config.ts +27 -54
- package/src/profile-catalog.ts +2 -2
- package/src/profile-resolver.ts +64 -114
- package/src/settings-generator.ts +44 -30
- package/src/switching/apply-plan.ts +9 -65
- package/src/switching/status.ts +11 -11
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,15 +91,17 @@ One profile can use every field at once. This example `impl` profile (`impl.json
|
|
|
95
91
|
How fields resolve:
|
|
96
92
|
|
|
97
93
|
- `skills`, `extensions`, `mcps`, `tools` take names or globs (e.g. `"internal-*"`) referencing resources you already installed or configured — profiles never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed.
|
|
98
|
-
- `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-
|
|
99
|
-
- `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
-
|
|
105
|
-
- **`mcps: []
|
|
94
|
+
- `tools` expands strictly against Pi's non-MCP tool registry — built-ins and extension-contributed tools, attributed by registration ownership (`sourceInfo`). Available MCP tools remain usable independently of `tools`.
|
|
95
|
+
- `mcp_tools` defines per-server MCP tool filtering: keys are literal configured server names and values are literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are not accepted. An omitted server keeps native access to all its tools; a nonempty array allows only matched tools; an empty array (`[]`) denies all tools for that server while leaving it enabled. Unmatched selectors remain restrictive and are not diagnosed, so confirm selectors with the server before writing them.
|
|
96
|
+
- **Migration notes:**
|
|
97
|
+
- Former MCP references in `tools` (e.g. `mcp__*`, `<server>_*`) no longer govern MCP access. Move desired MCP tool restrictions to `mcp_tools`.
|
|
98
|
+
- Prefixed selectors (e.g. `<server>_<tool>` forms used by previous MCP integrations) no longer apply; replace them with the literal tool names from Pi's built-in MCP extension.
|
|
99
|
+
- `mcps` references servers from your Pi user-level MCP configuration (`~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, and `<agentDir>/mcp.json`); connection details stay in those files.
|
|
100
|
+
- **Omitting `mcps`** leaves all discovered user-level servers at their normal availability.
|
|
101
|
+
- **`mcps: []`** disables every discovered user-level server, including agentDir-only servers; every unselected user-level server is explicitly marked `enabled: false` in the generated instance `mcp.json`. Project-level servers are never narrowed.
|
|
106
102
|
- Trusted project-level MCP servers are always kept enabled and are never narrowed by `mcps`.
|
|
103
|
+
- Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
|
|
104
|
+
- The instance `mcp.json` is always a generated snapshot of the merged user-level configuration. In-session `pi mcp add` edits the instance copy, and the next `/profile use` or `/profile reload` overwrites it with the profile's snapshot.
|
|
107
105
|
- Any field you omit keeps plain Pi behavior.
|
|
108
106
|
|
|
109
107
|
The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
|
package/bin/pi-profile.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/init
|
|
|
19
19
|
import { untrustedProjectDiagnostic } from "../src/launcher/untrusted-project-diagnostic.ts";
|
|
20
20
|
import { sweepStaleInstances } from "../src/launcher/runtime-cleanup.ts";
|
|
21
21
|
import { spawnPi } from "../src/launcher/spawn.ts";
|
|
22
|
-
import { McpConfigError
|
|
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,7 +227,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
227
227
|
);
|
|
228
228
|
const discoveredMcpServers = Object.keys(mcpDiscovery.servers).sort();
|
|
229
229
|
const disabledMcpServers = discoveredMcpServers.filter(
|
|
230
|
-
(server) => mcpDiscovery.servers[server]?.
|
|
230
|
+
(server) => mcpDiscovery.servers[server]?.enabled === false,
|
|
231
231
|
);
|
|
232
232
|
const report = buildStatusReport({
|
|
233
233
|
plan,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"@earendil-works/pi-coding-agent": "*",
|
|
37
37
|
"@types/node": "^24.0.0",
|
|
38
38
|
"ajv": "^8.20.0",
|
|
39
|
-
"pi-mcp-adapter": "^2.38.0",
|
|
40
39
|
"typescript": "^5.8.0",
|
|
41
40
|
"vitest": "^3.0.0"
|
|
42
41
|
},
|
|
@@ -54,9 +54,9 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
|
|
|
54
54
|
| `description` | `string` | Short description of the profile (e.g. `"Read-only review profile"`). |
|
|
55
55
|
| `skills` | `string[]` | Skill names or globs to reference. When undeclared, available skills are not narrowed. |
|
|
56
56
|
| `extensions` | `string[]` | Extension identifiers or globs to reference. When undeclared, available extensions are not narrowed. |
|
|
57
|
-
| `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, all discovered user-level servers keep their normal
|
|
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,9 +70,6 @@ All fields are optional. Undeclared fields keep native Pi behavior or current st
|
|
|
70
70
|
"skills": [
|
|
71
71
|
"profile-config"
|
|
72
72
|
],
|
|
73
|
-
"extensions": [
|
|
74
|
-
"pi-mcp-adapter"
|
|
75
|
-
],
|
|
76
73
|
"mcps": [],
|
|
77
74
|
"tools": [
|
|
78
75
|
"read",
|
|
@@ -106,17 +103,17 @@ When helping the user configure a profile, check or consult the following locati
|
|
|
106
103
|
- Absolute paths or `~/` paths.
|
|
107
104
|
- Glob matching.
|
|
108
105
|
3. **MCP servers**:
|
|
109
|
-
- Discovery locations:
|
|
106
|
+
- Discovery locations: Pi's user-level MCP configuration files — on the global side `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, `<agentDir>/mcp.json`; trusted projects additionally have `<projectDir>/.pi/mcp.json`.
|
|
110
107
|
- Reference identity: the server key names under the `mcpServers` object in those configuration files.
|
|
111
|
-
-
|
|
108
|
+
- Servers using `type: "sse"` cannot be selected; migrate them to streamable HTTP before referencing them in a profile.
|
|
112
109
|
4. **Tools**:
|
|
113
110
|
- Reference identity: non-MCP tool names in Pi's live tool registry.
|
|
114
111
|
- Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.) and extension-contributed tools.
|
|
115
112
|
- MCP-owned tools are **not** controlled by `tools`; legacy references such as `"mcp__*"` or `"github_*"` in `tools` must be migrated to `mcp_tools`.
|
|
116
113
|
5. **MCP Tools (`mcp_tools`)**:
|
|
117
|
-
- Reference identity: keys are literal configured MCP server names; values are literal
|
|
118
|
-
- Selectors are not checked against a live tool catalog. A selector matching no tool stays restrictive and produces no name diagnostic; verify selector forms with the
|
|
119
|
-
-
|
|
114
|
+
- Reference identity: keys are literal configured MCP server names; values are literal MCP tool names as exposed by Pi's built-in MCP extension. Globs are not accepted.
|
|
115
|
+
- Selectors are not checked against a live tool catalog. A selector matching no tool stays restrictive and produces no name diagnostic; verify selector forms with the MCP server before writing the profile.
|
|
116
|
+
- Prefixed selectors from previous MCP integrations (e.g. `<server>_<tool>`) no longer apply; replace them with the literal tool names from Pi's built-in MCP extension.
|
|
120
117
|
- Omitting a server key preserves unrestricted access to that server's tools.
|
|
121
118
|
- An empty list (`[]`) denies all tools for that server while keeping the server enabled.
|
|
122
119
|
- A nonempty list (e.g. `["search", "get_issue"]`) allows only those tools across direct tools, gateway proxies, and scripts.
|
|
@@ -35,7 +35,7 @@ export interface DiscoveredPackage {
|
|
|
35
35
|
* npm:/git:/github: prefix (and any version spec) when unreadable. */
|
|
36
36
|
name: string;
|
|
37
37
|
/** The source string exactly as configured in the user's settings
|
|
38
|
-
* (e.g. "npm:pi-
|
|
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 = {
|
|
@@ -163,15 +163,8 @@ export async function resolveInitialProfile(
|
|
|
163
163
|
const mcpToolsDef = (profile.definition as { mcp_tools?: Record<string, string[]> }).mcp_tools;
|
|
164
164
|
const hasMcpTools = mcpToolsDef !== undefined && Object.keys(mcpToolsDef).length > 0;
|
|
165
165
|
const declaredMcps = profile.definition.mcps;
|
|
166
|
-
const isEmptyMcps = declaredMcps !== undefined && declaredMcps.length === 0;
|
|
167
|
-
let adapterSelectedForEmptyMcps = false;
|
|
168
|
-
if (isEmptyMcps) {
|
|
169
|
-
const extSelection = await discovery.extensions.select(profile.definition.extensions ?? []);
|
|
170
|
-
adapterSelectedForEmptyMcps = extSelection.entries.some(isAdapterExtension);
|
|
171
|
-
}
|
|
172
166
|
const needsMcp = Boolean(
|
|
173
|
-
|
|
174
|
-
(isEmptyMcps && adapterSelectedForEmptyMcps) ||
|
|
167
|
+
declaredMcps !== undefined ||
|
|
175
168
|
hasMcpTools ||
|
|
176
169
|
(options?.overlay?.disabledMcps?.length ?? 0) > 0,
|
|
177
170
|
);
|
|
@@ -190,15 +183,5 @@ export async function resolveInitialProfile(
|
|
|
190
183
|
liveToolNames: options?.liveToolNames,
|
|
191
184
|
});
|
|
192
185
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
193
|
-
if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
|
|
194
|
-
throw new MissingMcpAdapterError(plan.profile, "mcps");
|
|
195
|
-
}
|
|
196
|
-
if (
|
|
197
|
-
plan.mcpTools !== undefined &&
|
|
198
|
-
Object.keys(plan.mcpTools).length > 0 &&
|
|
199
|
-
!plan.extensions.some(isAdapterExtension)
|
|
200
|
-
) {
|
|
201
|
-
throw new MissingMcpAdapterError(plan.profile, "mcp_tools");
|
|
202
|
-
}
|
|
203
186
|
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
204
187
|
}
|
package/src/mcp-config.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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[],
|
|
@@ -233,9 +230,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
233
230
|
});
|
|
234
231
|
|
|
235
232
|
// Extension references resolve directly against discovered extensions.
|
|
236
|
-
// Resolve extensions before MCP so an explicitly empty `mcps` array can
|
|
237
|
-
// be distinguished from omission based on whether the profile selects the
|
|
238
|
-
// adapter (honor-empty-mcp-allowlist).
|
|
239
233
|
const selection = await extensions.select(definition.extensions ?? []);
|
|
240
234
|
for (const reference of selection.unmatched) unmatched.push(`extension:${reference}`);
|
|
241
235
|
let planExtensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }> = selection.entries.map(
|
|
@@ -246,23 +240,34 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
246
240
|
}),
|
|
247
241
|
);
|
|
248
242
|
|
|
249
|
-
const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
|
|
250
|
-
|
|
251
243
|
let mcps: string[] | undefined;
|
|
252
244
|
if (definition.mcps !== undefined) {
|
|
253
245
|
if (definition.mcps.length > 0) {
|
|
254
246
|
if (input.discoveredMcpServers === undefined) {
|
|
255
247
|
throw new ActivationError(
|
|
256
|
-
`profile "${profile.name}" declares MCP servers but no
|
|
248
|
+
`profile "${profile.name}" declares MCP servers but no MCP server discovery is available`,
|
|
257
249
|
);
|
|
258
250
|
}
|
|
259
251
|
mcps = expandReferences(definition.mcps, input.discoveredMcpServers, (name) => name, "MCP server", {
|
|
260
252
|
onZeroMatch: (reference) => unmatched.push(`mcp:${reference}`),
|
|
261
253
|
});
|
|
262
|
-
|
|
254
|
+
if (input.mcpDiscovery !== undefined) {
|
|
255
|
+
const discovery = input.mcpDiscovery;
|
|
256
|
+
const projectNamed = mcps.find(
|
|
257
|
+
(name) => discovery.serverOwners[name] === "project" || discovery.projectServers.has(name),
|
|
258
|
+
);
|
|
259
|
+
if (projectNamed !== undefined) {
|
|
260
|
+
throw new ActivationError(
|
|
261
|
+
`profile "${profile.name}": cannot select project-level MCP server "${projectNamed}" (project-level servers are outside profile selection)`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
// Explicitly empty mcps: requires discovery so the empty selection
|
|
267
|
+
// disables every discovered user-level server.
|
|
263
268
|
if (input.mcpDiscovery === undefined) {
|
|
264
269
|
throw new ActivationError(
|
|
265
|
-
`profile "${profile.name}" declares MCP
|
|
270
|
+
`profile "${profile.name}" declares an empty MCP server selection but no MCP server discovery is available`,
|
|
266
271
|
);
|
|
267
272
|
}
|
|
268
273
|
mcps = [];
|
|
@@ -270,11 +275,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
// --- overlay narrowing (ticket 06) ---
|
|
273
|
-
// Overlay disable entries are names or globs stored as written and
|
|
274
|
-
// re-expanded at every resolution; unmatched literals fail and identify
|
|
275
|
-
// the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
|
|
276
|
-
// Overlays can disable any resolved extension.
|
|
277
|
-
const toolReferences = definition.tools;
|
|
278
278
|
if (overlay !== undefined) {
|
|
279
279
|
if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
|
|
280
280
|
const disabled = expandDisableEntries(
|
|
@@ -320,16 +320,9 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
320
320
|
|
|
321
321
|
let mcpTools: Record<string, string[]> | undefined;
|
|
322
322
|
if (mcpToolKeys.length > 0) {
|
|
323
|
-
const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
|
|
324
|
-
if (!hasAdapter) {
|
|
325
|
-
throw new ActivationError(
|
|
326
|
-
`profile "${profile.name}" declares MCP tools but pi-mcp-adapter is not active. ` +
|
|
327
|
-
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
323
|
if (input.mcpDiscovery === undefined) {
|
|
331
324
|
throw new ActivationError(
|
|
332
|
-
`profile "${profile.name}" declares MCP tools but no
|
|
325
|
+
`profile "${profile.name}" declares MCP tools but no MCP server discovery is available`,
|
|
333
326
|
);
|
|
334
327
|
}
|
|
335
328
|
|
|
@@ -340,7 +333,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
340
333
|
Object.hasOwn(mcpDiscovery.serverOwners, s) &&
|
|
341
334
|
mcpDiscovery.serverOwners[s] === "user" &&
|
|
342
335
|
!mcpDiscovery.projectServers.has(s);
|
|
343
|
-
const isEnabled = mcpDiscovery.servers[s]?.
|
|
336
|
+
const isEnabled = mcpDiscovery.servers[s]?.enabled !== false;
|
|
344
337
|
const isAllowedByMcps = mcps === undefined || mcps.includes(s);
|
|
345
338
|
return isUser && isEnabled && isAllowedByMcps;
|
|
346
339
|
})
|
|
@@ -359,7 +352,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
359
352
|
);
|
|
360
353
|
}
|
|
361
354
|
const isDisabled =
|
|
362
|
-
mcpDiscovery.servers[serverKey]?.
|
|
355
|
+
mcpDiscovery.servers[serverKey]?.enabled === false ||
|
|
363
356
|
(mcps !== undefined && !mcps.includes(serverKey));
|
|
364
357
|
if (isDisabled) {
|
|
365
358
|
throw new ActivationError(`profile "${profile.name}": MCP server "${serverKey}" is disabled${candidateMsg}`);
|
|
@@ -371,6 +364,8 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
371
364
|
);
|
|
372
365
|
}
|
|
373
366
|
|
|
367
|
+
const toolReferences = definition.tools;
|
|
368
|
+
|
|
374
369
|
let instanceMcpConfig: Record<string, unknown> | undefined;
|
|
375
370
|
if (input.mcpDiscovery !== undefined && (mcps !== undefined || mcpToolKeys.length > 0)) {
|
|
376
371
|
instanceMcpConfig = buildInstanceMcpConfig(profile.name, input.mcpDiscovery, mcps, mcpTools);
|
|
@@ -453,32 +448,6 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
453
448
|
};
|
|
454
449
|
}
|
|
455
450
|
|
|
456
|
-
function computeSafeMcpToolIntersection(
|
|
457
|
-
profileName: string,
|
|
458
|
-
serverName: string,
|
|
459
|
-
requestedTools: string[],
|
|
460
|
-
existingIncludes?: string[],
|
|
461
|
-
): string[] {
|
|
462
|
-
if (
|
|
463
|
-
requestedTools.length === 0 ||
|
|
464
|
-
existingIncludes === undefined ||
|
|
465
|
-
existingIncludes.length === 0 ||
|
|
466
|
-
existingIncludes.includes("*")
|
|
467
|
-
) {
|
|
468
|
-
return [...requestedTools];
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const unsafeSelector = requestedTools.find((selector) => !existingIncludes.includes(selector));
|
|
472
|
-
if (unsafeSelector !== undefined) {
|
|
473
|
-
throw new ActivationError(
|
|
474
|
-
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": ` +
|
|
475
|
-
`selector ${JSON.stringify(unsafeSelector)} is not an exact member of existing includeTools; ` +
|
|
476
|
-
`use identical literals or an existing "*" allowlist to prove the restriction is safe`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
return [...requestedTools];
|
|
480
|
-
}
|
|
481
|
-
|
|
482
451
|
function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
|
|
483
452
|
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
484
453
|
}
|
|
@@ -502,61 +471,42 @@ export function buildInstanceMcpConfig(
|
|
|
502
471
|
const originalDef = Object.hasOwn(mcpDiscovery.servers, serverName)
|
|
503
472
|
? mcpDiscovery.servers[serverName]
|
|
504
473
|
: undefined;
|
|
505
|
-
if (originalDef
|
|
506
|
-
const def = { ...originalDef };
|
|
507
|
-
if (mcps !== undefined) {
|
|
508
|
-
delete def.disabled;
|
|
509
|
-
}
|
|
474
|
+
if (originalDef === undefined) continue;
|
|
510
475
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
throw new ActivationError(
|
|
517
|
-
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing includeTools`,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
if (
|
|
521
|
-
def.excludeTools !== undefined &&
|
|
522
|
-
(!Array.isArray(def.excludeTools) || !def.excludeTools.every((t) => typeof t === "string"))
|
|
523
|
-
) {
|
|
524
|
-
throw new ActivationError(
|
|
525
|
-
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing excludeTools`,
|
|
526
|
-
);
|
|
527
|
-
}
|
|
476
|
+
// Project-owned servers are never materialized into the snapshot;
|
|
477
|
+
// Pi reads the trusted project's .pi/mcp.json itself.
|
|
478
|
+
const isProjectOwned =
|
|
479
|
+
mcpDiscovery.serverOwners[serverName] === "project" || mcpDiscovery.projectServers.has(serverName);
|
|
480
|
+
if (isProjectOwned) continue;
|
|
528
481
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
482
|
+
// A server explicitly selected by mcps whose definition uses a
|
|
483
|
+
// transport Pi's built-in MCP extension cannot use fails activation.
|
|
484
|
+
if (mcps?.includes(serverName) === true && originalDef.type === "sse") {
|
|
485
|
+
throw new ActivationError(
|
|
486
|
+
`profile "${profileName}": selected MCP server "${serverName}" uses the legacy SSE transport, which Pi does not support; switch to the server's streamable HTTP URL or remove it from "mcps"`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
532
489
|
|
|
533
|
-
|
|
534
|
-
profileName,
|
|
535
|
-
serverName,
|
|
536
|
-
requested,
|
|
537
|
-
existingIncludes,
|
|
538
|
-
);
|
|
490
|
+
const def = { ...originalDef };
|
|
539
491
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def.includeTools = safeTools;
|
|
547
|
-
}
|
|
492
|
+
if (mcpTools && Object.hasOwn(mcpTools, serverName)) {
|
|
493
|
+
// Profile policy replaces the merged toolExposure wholesale.
|
|
494
|
+
const requested = mcpTools[serverName];
|
|
495
|
+
const exposure: Record<string, string> = { "*": "hidden" };
|
|
496
|
+
for (const selector of requested) {
|
|
497
|
+
exposure[selector] = "direct";
|
|
548
498
|
}
|
|
549
|
-
|
|
550
|
-
setOwnRecordValue(filteredServers, serverName, def);
|
|
499
|
+
def.toolExposure = exposure;
|
|
551
500
|
}
|
|
501
|
+
|
|
502
|
+
setOwnRecordValue(filteredServers, serverName, def);
|
|
552
503
|
}
|
|
553
504
|
|
|
505
|
+
// Unselected user-level servers are explicitly disabled.
|
|
554
506
|
if (mcps !== undefined) {
|
|
555
|
-
for (const
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
setOwnRecordValue(filteredServers, sharedName, { disabled: true });
|
|
559
|
-
}
|
|
507
|
+
for (const userName of userServers) {
|
|
508
|
+
if (mcps.includes(userName)) continue;
|
|
509
|
+
setOwnRecordValue(filteredServers, userName, { enabled: false });
|
|
560
510
|
}
|
|
561
511
|
}
|
|
562
512
|
|
|
@@ -170,6 +170,29 @@ function homeAgentsSkillsDir(): string {
|
|
|
170
170
|
return path.join(process.env.HOME ?? homedir(), ".agents", "skills");
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** The user's own skill exclusions (`!pattern`, `-path`), carried into a
|
|
174
|
+
* named profile's generated settings. Relative entries resolve against the
|
|
175
|
+
* runtime dir, which mirrors the agent dir, so they pass through unchanged.
|
|
176
|
+
* An absolute (or `~`) `-path` under the agent dir is rewritten to its
|
|
177
|
+
* runtime mirror path: Pi matches `-` entries lexically. */
|
|
178
|
+
function userSkillExclusions(userSkills: unknown, agentDir: string, runtimeDir: string): string[] {
|
|
179
|
+
if (!Array.isArray(userSkills)) return [];
|
|
180
|
+
const exclusions: string[] = [];
|
|
181
|
+
for (const entry of userSkills) {
|
|
182
|
+
if (typeof entry !== "string") continue;
|
|
183
|
+
if (entry.startsWith("!")) {
|
|
184
|
+
exclusions.push(entry);
|
|
185
|
+
} else if (entry.startsWith("-")) {
|
|
186
|
+
const target = entry.slice(1);
|
|
187
|
+
const expanded = target === "~" || target.startsWith("~/") ? path.join(process.env.HOME ?? homedir(), target.slice(1)) : target;
|
|
188
|
+
const rel = path.isAbsolute(expanded) ? path.relative(agentDir, expanded) : undefined;
|
|
189
|
+
const underAgentDir = rel !== undefined && rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
190
|
+
exclusions.push(underAgentDir ? `-${path.join(runtimeDir, rel)}` : entry);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return exclusions;
|
|
194
|
+
}
|
|
195
|
+
|
|
173
196
|
function buildSelectionSettings(
|
|
174
197
|
plan: ActivationPlan,
|
|
175
198
|
userSettings: Record<string, unknown>,
|
|
@@ -213,6 +236,11 @@ function buildSelectionSettings(
|
|
|
213
236
|
skillEntries.push(`-${skill.filePath}`);
|
|
214
237
|
}
|
|
215
238
|
}
|
|
239
|
+
// Discovery already honored the user's own `!pattern` / `-path` skill
|
|
240
|
+
// exclusions, so the skills they hide never reach the loop above and get no
|
|
241
|
+
// `-` entry. Replacing the user's array would then drop those exclusions and
|
|
242
|
+
// let the instance's skills symlink and ~/.agents/skills reveal them.
|
|
243
|
+
skillEntries.push(...userSkillExclusions(userSettings.skills, agentDir, runtimeDir));
|
|
216
244
|
settings.skills = skillEntries;
|
|
217
245
|
|
|
218
246
|
// --- extensions ---
|
|
@@ -430,37 +458,23 @@ export async function writeRuntimeFiles(
|
|
|
430
458
|
await symlink(path.join(options.agentDir, "trust.json"), trustLink);
|
|
431
459
|
}
|
|
432
460
|
|
|
433
|
-
// MCP Servers generation
|
|
434
|
-
|
|
461
|
+
// MCP Servers generation: the instance mcp.json is always a generated
|
|
462
|
+
// snapshot of the merged user-level configuration; it is never a symlink
|
|
463
|
+
// or a copy of the real agentDir file (ADR-0016).
|
|
435
464
|
const mcpInstancePath = path.join(runtimeDir, "mcp.json");
|
|
436
|
-
|
|
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
|
-
}
|
|
465
|
+
try { await rm(mcpInstancePath); } catch {}
|
|
466
|
+
const discovery = await loadMergedMcpServers(
|
|
467
|
+
options.agentDir,
|
|
468
|
+
options.projectDir,
|
|
469
|
+
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
470
|
+
);
|
|
471
|
+
const instanceMcpConfig = buildInstanceMcpConfig(
|
|
472
|
+
plan.profile,
|
|
473
|
+
discovery,
|
|
474
|
+
plan.mcps,
|
|
475
|
+
plan.mcpTools,
|
|
476
|
+
);
|
|
477
|
+
await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
|
|
464
478
|
|
|
465
479
|
// Instructions generation (Ticket 04)
|
|
466
480
|
const appendSystemPath = path.join(runtimeDir, "APPEND_SYSTEM.md");
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Steps:
|
|
11
11
|
* 1. tools: re-expand the profile's raw references against Pi's LIVE
|
|
12
12
|
* non-MCP tool registry and call setActiveTools while retaining
|
|
13
|
-
*
|
|
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,8 +73,8 @@ export function buildStatusReport(input: {
|
|
|
73
73
|
overlay?: RuntimeOverlay;
|
|
74
74
|
discoveredMcpServers: string[];
|
|
75
75
|
disabledMcpServers: string[];
|
|
76
|
-
/** Project-owned server names from trust-gated MCP discovery;
|
|
77
|
-
* when the profile declares a defined `mcps` selection. */
|
|
76
|
+
/** Project-owned server names from trust-gated MCP snapshot discovery;
|
|
77
|
+
* kept enabled when the profile declares a defined `mcps` selection. */
|
|
78
78
|
projectMcpServers?: string[];
|
|
79
79
|
commands: RegisteredCommand[];
|
|
80
80
|
tools: RegisteredTool[];
|
|
@@ -87,16 +87,16 @@ export function buildStatusReport(input: {
|
|
|
87
87
|
let missing: string[];
|
|
88
88
|
|
|
89
89
|
if (plan.mcps === undefined) {
|
|
90
|
-
const
|
|
91
|
-
enabled = input.discoveredMcpServers.filter((name) => !
|
|
92
|
-
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));
|
|
93
93
|
missing = [];
|
|
94
94
|
} else {
|
|
95
95
|
const projectMcpServers = new Set(input.projectMcpServers ?? []);
|
|
96
|
-
const
|
|
96
|
+
const snapshotDisabled = new Set(input.disabledMcpServers);
|
|
97
97
|
const explicitMcps = new Set(plan.mcps);
|
|
98
98
|
const projectEnabled = input.discoveredMcpServers.filter(
|
|
99
|
-
(name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !
|
|
99
|
+
(name) => projectMcpServers.has(name) && !explicitMcps.has(name) && !snapshotDisabled.has(name),
|
|
100
100
|
);
|
|
101
101
|
enabled = [...plan.mcps, ...projectEnabled].sort();
|
|
102
102
|
disabled = input.discoveredMcpServers.filter((name) => !enabled.includes(name));
|