pi-profile-switch 0.4.2 → 0.4.4
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 +0 -1
- package/README.zh-CN.md +0 -1
- package/bin/pi-profile.ts +2 -2
- package/extensions/pi-profile/index.ts +0 -70
- package/package.json +1 -1
- package/src/launcher/initial-profile.ts +4 -2
- package/src/mcp-config.ts +108 -38
- package/src/settings-generator.ts +35 -21
- package/src/switching/switch-profile.ts +1 -0
- package/src/switching/mcp-toggle.ts +0 -71
package/README.md
CHANGED
|
@@ -59,7 +59,6 @@ In the TUI, the `/profile` command family manages everything in-session:
|
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | Switch / reload without restarting (rollback on failure) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | Guided profile CRUD (TUI only) |
|
|
61
61
|
| `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
|
|
62
|
-
| `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
|
|
63
62
|
|
|
64
63
|
All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
|
|
65
64
|
|
package/README.zh-CN.md
CHANGED
|
@@ -59,7 +59,6 @@ Profile 只**引用**资源,从不复制资源。已安装的包和标准目
|
|
|
59
59
|
| `/profile use <name>` / `/profile reload` | 会话内切换 / 重载(失败自动回滚) |
|
|
60
60
|
| `/profile create\|edit\|delete\|duplicate` | 向导式 profile 增删改(仅 TUI) |
|
|
61
61
|
| `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
|
|
62
|
-
| `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
|
|
63
62
|
|
|
64
63
|
非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
|
|
65
64
|
|
package/bin/pi-profile.ts
CHANGED
|
@@ -29,7 +29,7 @@ try {
|
|
|
29
29
|
const agentDir = getAgentDir();
|
|
30
30
|
// Fails before spawning when the profile is unknown or cannot activate.
|
|
31
31
|
// --approve/--no-approve are consumed here as a one-run trust input.
|
|
32
|
-
const { plan, discovery, projectSettings, warnings } = await resolveInitialProfile(args.profile, {
|
|
32
|
+
const { plan, discovery, projectSettings, projectDir, warnings } = await resolveInitialProfile(args.profile, {
|
|
33
33
|
agentDir,
|
|
34
34
|
cwd: process.cwd(),
|
|
35
35
|
trustOverride: args.trustOverride,
|
|
@@ -41,7 +41,7 @@ try {
|
|
|
41
41
|
// window) are swept before this launch materializes its own. Best-effort:
|
|
42
42
|
// sweep errors never block the launch.
|
|
43
43
|
await sweepStaleRuntimeDirs(agentDir);
|
|
44
|
-
const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings });
|
|
44
|
+
const generated = await generateRuntimeDir(plan, { agentDir, discovery, projectSettings, projectDir });
|
|
45
45
|
process.exitCode = await spawnPi({
|
|
46
46
|
generated,
|
|
47
47
|
piArgs: args.piArgs,
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
runProfileDuplicateWizard,
|
|
22
22
|
runProfileEditWizard,
|
|
23
23
|
} from "../../src/switching/profile-wizard.ts";
|
|
24
|
-
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
25
|
-
import { setMcpServerEnabled } from "../../src/switching/mcp-toggle.ts";
|
|
26
24
|
import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/status.ts";
|
|
27
25
|
import { switchProfile, type SwitchDeps } from "../../src/switching/switch-profile.ts";
|
|
28
26
|
import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
@@ -53,9 +51,6 @@ import { getGlobalStateDir } from "../../src/workspace.ts";
|
|
|
53
51
|
* is stale afterwards.
|
|
54
52
|
* - list/status ship structured `details` payloads (`{kind, profiles}` /
|
|
55
53
|
* `{kind, report}`) for RPC consumers (ticket 11).
|
|
56
|
-
* - `/mcp enable|disable <server>`: edit the active profile's mcp array in
|
|
57
|
-
* its owning catalog, then reload (ticket 10). Fails clearly with the
|
|
58
|
-
* adapter absent; notifies before the reload (stale context after).
|
|
59
54
|
*
|
|
60
55
|
* Pi re-executes this module on reload, so post-reload state is established
|
|
61
56
|
* exclusively through `session_start` — nothing stale survives.
|
|
@@ -383,69 +378,4 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
383
378
|
}
|
|
384
379
|
},
|
|
385
380
|
});
|
|
386
|
-
|
|
387
|
-
// Persistent profile-scoped MCP toggles (ticket 10): edit the active
|
|
388
|
-
// profile's mcp array in its owning catalog, then reload so the runtime
|
|
389
|
-
// and the republished allowlist match. The adapter's own configuration
|
|
390
|
-
// (mcp.json files) is never written.
|
|
391
|
-
pi.registerCommand("mcp", {
|
|
392
|
-
description: "pi-profile: /mcp enable <server> | /mcp disable <server>",
|
|
393
|
-
handler: async (args, ctx) => {
|
|
394
|
-
const notify = (message: string, level: "info" | "warning" | "error") => {
|
|
395
|
-
try {
|
|
396
|
-
ctx.ui?.notify(message, level);
|
|
397
|
-
} catch {
|
|
398
|
-
// stale context after reload — see /profile
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
const [action, server] = args.trim().split(/\s+/).filter(Boolean);
|
|
402
|
-
if (!["enable", "disable"].includes(action ?? "") || server === undefined) {
|
|
403
|
-
notify("usage: /mcp enable <server> | /mcp disable <server>", "error");
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
try {
|
|
407
|
-
const plan = await readLaunchPlanFile(runtimeDir);
|
|
408
|
-
if (plan?.agentDir === undefined) {
|
|
409
|
-
notify("pi-profile: launch plan is missing agentDir — cannot toggle MCP servers", "error");
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
if (!probeAdapterPresence(pi.events)) {
|
|
413
|
-
throw new Error(
|
|
414
|
-
"pi-mcp-adapter is not active in this session — /mcp enable|disable requires it " +
|
|
415
|
-
"(select the adapter in the profile's extensions).",
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
const result = await setMcpServerEnabled(
|
|
419
|
-
{ realAgentDir: plan.agentDir, cwd: ctx.cwd, profile: { name: plan.profile, source: plan.source } },
|
|
420
|
-
server,
|
|
421
|
-
action === "enable",
|
|
422
|
-
);
|
|
423
|
-
if (!result.changed) {
|
|
424
|
-
notify(`MCP server "${server}" is already ${action}d in profile "${plan.profile}"`, "info");
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
// Notify BEFORE the reload: this context is stale afterwards.
|
|
428
|
-
notify(
|
|
429
|
-
`${action}d MCP server "${server}" in profile "${plan.profile}" (mcps: [${result.mcps.join(", ")}]); reloading`,
|
|
430
|
-
"info",
|
|
431
|
-
);
|
|
432
|
-
const switched = await switchProfile(plan.profile, {
|
|
433
|
-
runtimeDir,
|
|
434
|
-
realAgentDir: plan.agentDir,
|
|
435
|
-
cwd: ctx.cwd,
|
|
436
|
-
waitForIdle: () => ctx.waitForIdle(),
|
|
437
|
-
reload: () => ctx.reload(),
|
|
438
|
-
// Same staleness probe as /profile: interactive Pi
|
|
439
|
-
// swallows reload refusals; a live context afterwards
|
|
440
|
-
// means the reload never ran.
|
|
441
|
-
assertStale: () => {
|
|
442
|
-
void ctx.cwd;
|
|
443
|
-
},
|
|
444
|
-
}, { reloadCurrent: true });
|
|
445
|
-
for (const warning of switched.warnings) notify(warning, "warning");
|
|
446
|
-
} catch (error) {
|
|
447
|
-
notify(error instanceof Error ? error.message : String(error), "error");
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
});
|
|
451
381
|
}
|
package/package.json
CHANGED
|
@@ -56,6 +56,8 @@ export interface InitialProfile {
|
|
|
56
56
|
/** The trusted project's `.pi/settings.json` content, when trusted and
|
|
57
57
|
* present. The generator merges it into the base for selection plans. */
|
|
58
58
|
projectSettings?: Record<string, unknown>;
|
|
59
|
+
/** The trusted project directory, when trusted. */
|
|
60
|
+
projectDir?: string;
|
|
59
61
|
/** Non-fatal notices for the user (e.g. a dangling restored profile that
|
|
60
62
|
* fell back to default). The launcher prints them. */
|
|
61
63
|
warnings: string[];
|
|
@@ -154,7 +156,7 @@ export async function resolveInitialProfile(
|
|
|
154
156
|
overlay,
|
|
155
157
|
});
|
|
156
158
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
157
|
-
return { plan, discovery, projectSettings, warnings };
|
|
159
|
+
return { plan, discovery, projectSettings, projectDir, warnings };
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
@@ -175,5 +177,5 @@ export async function resolveInitialProfile(
|
|
|
175
177
|
// silently do nothing or leak through unfiltered.
|
|
176
178
|
throw new MissingMcpAdapterError(plan.profile);
|
|
177
179
|
}
|
|
178
|
-
return { plan, discovery, projectSettings, warnings };
|
|
180
|
+
return { plan, discovery, projectSettings, projectDir, warnings };
|
|
179
181
|
}
|
package/src/mcp-config.ts
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AdapterConfigDiscovery: reads
|
|
3
|
-
* discover from its
|
|
2
|
+
* AdapterConfigDiscovery: reads MCP server names and configurations
|
|
3
|
+
* pi-mcp-adapter would discover from its standard configuration files,
|
|
4
|
+
* without ever managing them.
|
|
4
5
|
*
|
|
5
6
|
* pi-profile never stores MCP connection parameters or credentials
|
|
6
|
-
* (ADR-0002); this module reads
|
|
7
|
-
*
|
|
7
|
+
* (ADR-0002); this module reads config definitions to validate references
|
|
8
|
+
* before spawn and filter instance mcp.json files.
|
|
8
9
|
*
|
|
9
|
-
* Discovery scope
|
|
10
|
-
*
|
|
10
|
+
* Discovery scope: standard user-global configs (~/.config/mcp/mcp.json,
|
|
11
|
+
* ~/.agents/mcp.json, ~/.agents/mcp/mcp.json), the Pi-global
|
|
12
|
+
* `<agentDir>/mcp.json`, and, when trusted, the project's `.mcp.json` and
|
|
11
13
|
* `.pi/mcp.json`. Servers defined solely in the adapter's editor-specific
|
|
12
|
-
* legacy locations (~/.claude/mcp.json et al.) are invisible here
|
|
13
|
-
*
|
|
14
|
-
* adapter's documented default, and no in-session re-validation exists (the
|
|
15
|
-
* adapter's status snapshots arrive too late and only post-init), so the
|
|
16
|
-
* launch check is the only name validation — keep configs in the pi-native
|
|
17
|
-
* files.
|
|
14
|
+
* legacy locations (~/.claude/mcp.json et al.) are invisible here unless
|
|
15
|
+
* imported.
|
|
18
16
|
*
|
|
19
17
|
* Malformed config files fail loudly — a broken mcp.json must not silently
|
|
20
18
|
* read as "no servers" and reject every reference.
|
|
21
19
|
*/
|
|
22
20
|
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
23
24
|
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
24
25
|
|
|
25
26
|
export class McpConfigError extends Error {
|
|
@@ -32,35 +33,104 @@ export class McpConfigError extends Error {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (!result.ok) {
|
|
38
|
-
if (result.reason === "missing") {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
throw new McpConfigError(`MCP config is not valid JSON: ${filePath}`, filePath);
|
|
42
|
-
}
|
|
43
|
-
if (!isRecord(result.value)) {
|
|
44
|
-
throw new McpConfigError(`MCP config must be a JSON object: ${filePath}`, filePath);
|
|
45
|
-
}
|
|
46
|
-
if (result.value.mcpServers === undefined) {
|
|
47
|
-
return [];
|
|
48
|
-
}
|
|
49
|
-
if (!isRecord(result.value.mcpServers)) {
|
|
50
|
-
throw new McpConfigError(`"mcpServers" must be a JSON object: ${filePath}`, filePath);
|
|
51
|
-
}
|
|
52
|
-
return Object.keys(result.value.mcpServers);
|
|
36
|
+
export interface McpDiscoveryOptions {
|
|
37
|
+
homeDir?: string;
|
|
53
38
|
}
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
40
|
+
export interface MergedMcpResult {
|
|
41
|
+
servers: Record<string, Record<string, unknown>>;
|
|
42
|
+
sharedServers: Set<string>;
|
|
43
|
+
baseConfig?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface McpConfigSource {
|
|
47
|
+
path: string;
|
|
48
|
+
isShared: boolean;
|
|
49
|
+
isAgentDir?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Standard MCP configuration sources recognized by pi-mcp-adapter in precedence order:
|
|
54
|
+
* 1. ~/.config/mcp/mcp.json (user-global standard MCP)
|
|
55
|
+
* 2. ~/.agents/mcp.json (user-global .agents MCP)
|
|
56
|
+
* 3. ~/.agents/mcp/mcp.json (user-global .agents nested MCP)
|
|
57
|
+
* 4. <agentDir>/mcp.json (Pi global override)
|
|
58
|
+
* 5. <projectDir>/.mcp.json (project standard MCP, when project is trusted)
|
|
59
|
+
* 6. <projectDir>/.pi/mcp.json (project Pi override, when project is trusted)
|
|
60
|
+
*/
|
|
61
|
+
export function getStandardMcpConfigSources(
|
|
62
|
+
agentDir: string,
|
|
63
|
+
projectDir?: string,
|
|
64
|
+
options?: McpDiscoveryOptions,
|
|
65
|
+
): McpConfigSource[] {
|
|
66
|
+
const home = options?.homeDir ?? process.env.HOME ?? homedir();
|
|
67
|
+
const sources: McpConfigSource[] = [
|
|
68
|
+
{ path: path.join(home, ".config", "mcp", "mcp.json"), isShared: true },
|
|
69
|
+
{ path: path.join(home, ".agents", "mcp.json"), isShared: true },
|
|
70
|
+
{ path: path.join(home, ".agents", "mcp", "mcp.json"), isShared: true },
|
|
71
|
+
{ path: path.join(agentDir, "mcp.json"), isShared: false, isAgentDir: true },
|
|
72
|
+
];
|
|
60
73
|
if (projectDir !== undefined) {
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
sources.push({ path: path.join(projectDir, ".mcp.json"), isShared: true });
|
|
75
|
+
sources.push({ path: path.join(projectDir, ".pi", "mcp.json"), isShared: false });
|
|
76
|
+
}
|
|
77
|
+
return sources;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function loadMergedMcpServers(
|
|
81
|
+
agentDir: string,
|
|
82
|
+
projectDir?: string,
|
|
83
|
+
options?: McpDiscoveryOptions,
|
|
84
|
+
): Promise<MergedMcpResult> {
|
|
85
|
+
const sources = getStandardMcpConfigSources(agentDir, projectDir, options);
|
|
86
|
+
const seenPaths = new Set<string>();
|
|
87
|
+
const servers: Record<string, Record<string, unknown>> = {};
|
|
88
|
+
const sharedServers = new Set<string>();
|
|
89
|
+
let baseConfig: Record<string, unknown> | undefined;
|
|
90
|
+
|
|
91
|
+
for (const source of sources) {
|
|
92
|
+
const resolvedPath = path.resolve(source.path);
|
|
93
|
+
if (seenPaths.has(resolvedPath)) continue;
|
|
94
|
+
seenPaths.add(resolvedPath);
|
|
95
|
+
|
|
96
|
+
const result = await readJsonFile(resolvedPath);
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
if (result.reason === "missing") continue;
|
|
99
|
+
throw new McpConfigError(`MCP config is not valid JSON: ${resolvedPath}`, resolvedPath);
|
|
100
|
+
}
|
|
101
|
+
if (!isRecord(result.value)) {
|
|
102
|
+
throw new McpConfigError(`MCP config must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
103
|
+
}
|
|
104
|
+
if (source.isAgentDir) {
|
|
105
|
+
baseConfig = result.value;
|
|
106
|
+
}
|
|
107
|
+
if (result.value.mcpServers === undefined) continue;
|
|
108
|
+
if (!isRecord(result.value.mcpServers)) {
|
|
109
|
+
throw new McpConfigError(`"mcpServers" must be a JSON object: ${resolvedPath}`, resolvedPath);
|
|
110
|
+
}
|
|
111
|
+
for (const [name, def] of Object.entries(result.value.mcpServers)) {
|
|
112
|
+
if (source.isShared) {
|
|
113
|
+
sharedServers.add(name);
|
|
114
|
+
}
|
|
115
|
+
if (isRecord(def)) {
|
|
116
|
+
servers[name] = { ...(servers[name] ?? {}), ...def };
|
|
117
|
+
} else {
|
|
118
|
+
servers[name] = { ...(servers[name] ?? {}) };
|
|
119
|
+
}
|
|
63
120
|
}
|
|
64
121
|
}
|
|
65
|
-
|
|
122
|
+
|
|
123
|
+
return { servers, sharedServers, baseConfig };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Server names the adapter would discover: standard global MCP configs,
|
|
127
|
+
* global agentDir config, plus the trusted project's configs. Pass `projectDir`
|
|
128
|
+
* only when the trust check passed — an untrusted project's config is never read. */
|
|
129
|
+
export async function discoverAdapterServerNames(
|
|
130
|
+
agentDir: string,
|
|
131
|
+
projectDir?: string,
|
|
132
|
+
options?: McpDiscoveryOptions,
|
|
133
|
+
): Promise<string[]> {
|
|
134
|
+
const { servers } = await loadMergedMcpServers(agentDir, projectDir, options);
|
|
135
|
+
return Object.keys(servers).sort();
|
|
66
136
|
}
|
|
@@ -45,6 +45,7 @@ import path from "node:path";
|
|
|
45
45
|
import type { ActivationPlan } from "./profile-resolver.ts";
|
|
46
46
|
import { getInstancesRootDir } from "./workspace.ts";
|
|
47
47
|
import { isRecord } from "./json-file.ts";
|
|
48
|
+
import { loadMergedMcpServers } from "./mcp-config.ts";
|
|
48
49
|
import type { SkillEntry } from "./skill-registry.ts";
|
|
49
50
|
|
|
50
51
|
/** A configured global package and its resolved install/local root. */
|
|
@@ -66,6 +67,10 @@ export interface DiscoveryContext {
|
|
|
66
67
|
export interface GenerateOptions {
|
|
67
68
|
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
68
69
|
agentDir: string;
|
|
70
|
+
/** Optional home dir override (useful for testing). */
|
|
71
|
+
homeDir?: string;
|
|
72
|
+
/** Optional trusted project dir. */
|
|
73
|
+
projectDir?: string;
|
|
69
74
|
/** Required for selection plans; unused for the default profile. */
|
|
70
75
|
discovery?: DiscoveryContext;
|
|
71
76
|
/** The trusted project's `.pi/settings.json` content (already parsed).
|
|
@@ -276,6 +281,10 @@ function buildSelectionSettings(
|
|
|
276
281
|
export interface RuntimeFileOptions {
|
|
277
282
|
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
278
283
|
agentDir: string;
|
|
284
|
+
/** Optional home dir override (useful for testing). */
|
|
285
|
+
homeDir?: string;
|
|
286
|
+
/** Optional trusted project dir. */
|
|
287
|
+
projectDir?: string;
|
|
279
288
|
/** Required for selection plans; unused for the default profile. */
|
|
280
289
|
discovery?: DiscoveryContext;
|
|
281
290
|
/** The trusted project's `.pi/settings.json` content (already parsed). */
|
|
@@ -418,29 +427,34 @@ export async function writeRuntimeFiles(
|
|
|
418
427
|
} else {
|
|
419
428
|
// Filter MCP servers
|
|
420
429
|
try { await rm(mcpInstancePath); } catch {}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
430
|
+
const { servers, sharedServers, baseConfig } = await loadMergedMcpServers(
|
|
431
|
+
options.agentDir,
|
|
432
|
+
options.projectDir,
|
|
433
|
+
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const allowedSet = new Set(plan.mcps);
|
|
437
|
+
const filteredServers: Record<string, unknown> = {};
|
|
438
|
+
|
|
439
|
+
for (const serverName of plan.mcps) {
|
|
440
|
+
if (servers[serverName] !== undefined) {
|
|
441
|
+
const def = { ...servers[serverName] };
|
|
442
|
+
delete def.disabled;
|
|
443
|
+
filteredServers[serverName] = def;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const sharedName of sharedServers) {
|
|
448
|
+
if (!allowedSet.has(sharedName)) {
|
|
449
|
+
filteredServers[sharedName] = { disabled: true };
|
|
440
450
|
}
|
|
441
|
-
} else {
|
|
442
|
-
await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
|
|
443
451
|
}
|
|
452
|
+
|
|
453
|
+
const outputConfig: Record<string, unknown> = isRecord(baseConfig)
|
|
454
|
+
? { ...baseConfig, mcpServers: filteredServers }
|
|
455
|
+
: { mcpServers: filteredServers };
|
|
456
|
+
|
|
457
|
+
await writeFile(mcpInstancePath, JSON.stringify(outputConfig, null, 2));
|
|
444
458
|
}
|
|
445
459
|
|
|
446
460
|
// Instructions generation (Ticket 04)
|
|
@@ -164,6 +164,7 @@ export async function switchProfile(
|
|
|
164
164
|
: undefined;
|
|
165
165
|
await writeRuntimeFiles(deps.runtimeDir, resolved.plan, {
|
|
166
166
|
agentDir: deps.realAgentDir,
|
|
167
|
+
projectDir: resolved.projectDir,
|
|
167
168
|
discovery: resolved.discovery,
|
|
168
169
|
projectSettings: resolved.projectSettings,
|
|
169
170
|
planExtras: {
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* McpToggle: persistent, profile-scoped `/mcp enable|disable` (ticket 10).
|
|
3
|
-
*
|
|
4
|
-
* The profile's `mcps` array in its OWNING catalog is the profile-scoped
|
|
5
|
-
* state store (ticket 04 established that pi-mcp-adapter@2.33.0 has no
|
|
6
|
-
* allowlist/profile-state API — ADR-0002's assumed store does not exist;
|
|
7
|
-
* pi-profile owns the contract). Runtime effect flows through the standard
|
|
8
|
-
* rewrite-settings-and-reload path: the post-reload session_start
|
|
9
|
-
* republishes the allowlist over the coordination channel.
|
|
10
|
-
*
|
|
11
|
-
* Invariants:
|
|
12
|
-
* - Enable accepts only adapter-discovered names (fail fast on typos);
|
|
13
|
-
* disable also removes names the adapter no longer reports (stale
|
|
14
|
-
* cleanup).
|
|
15
|
-
* - The built-in default profile has no catalog entry — toggling it is a
|
|
16
|
-
* clear error, not a synthetic write.
|
|
17
|
-
* - Only the ACTIVE profile's array changes; other profiles and the
|
|
18
|
-
* adapter's own configuration (global mcp.json, project .pi/mcp.json)
|
|
19
|
-
* are never modified.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { discoverAdapterServerNames } from "../mcp-config.ts";
|
|
23
|
-
import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
|
|
24
|
-
import { readTrustInputs as readTrust } from "../launcher/initial-profile.ts";
|
|
25
|
-
import { catalogStore, readCatalogScope, type CatalogScope } from "./profile-crud.ts";
|
|
26
|
-
|
|
27
|
-
export async function setMcpServerEnabled(
|
|
28
|
-
input: { realAgentDir: string; cwd: string; profile: { name: string; source: string } },
|
|
29
|
-
server: string,
|
|
30
|
-
enabled: boolean,
|
|
31
|
-
): Promise<{ mcps: string[]; changed: boolean }> {
|
|
32
|
-
if (input.profile.name === DEFAULT_PROFILE_NAME || input.profile.source === "builtin") {
|
|
33
|
-
throw new CatalogError(
|
|
34
|
-
`the built-in default profile has no catalog entry — create a named profile (/profile create) to toggle MCP servers`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
const scope = input.profile.source as CatalogScope;
|
|
38
|
-
if (scope !== "global" && scope !== "project") {
|
|
39
|
-
throw new CatalogError(`profile "${input.profile.name}" has no writable owning catalog (source: ${input.profile.source})`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const { projectTrusted } = await readTrust({ agentDir: input.realAgentDir, cwd: input.cwd });
|
|
43
|
-
if (scope === "project" && !projectTrusted) {
|
|
44
|
-
throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
|
|
45
|
-
}
|
|
46
|
-
const discovered = await discoverAdapterServerNames(input.realAgentDir, projectTrusted ? input.cwd : undefined);
|
|
47
|
-
if (enabled && !discovered.includes(server)) {
|
|
48
|
-
throw new CatalogError(
|
|
49
|
-
`unknown MCP server "${server}" — adapter discovered: [${discovered.join(", ") || "(none)"}]`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const definitions = await readCatalogScope(input, scope);
|
|
54
|
-
const definition = definitions.get(input.profile.name);
|
|
55
|
-
if (definition === undefined) {
|
|
56
|
-
throw new CatalogError(`profile "${input.profile.name}" not found in the ${scope} catalog`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const current = definition.mcps ?? [];
|
|
60
|
-
if (enabled === current.includes(server)) {
|
|
61
|
-
return { mcps: current, changed: false }; // already in the requested state
|
|
62
|
-
}
|
|
63
|
-
const next = enabled ? [...current, server] : current.filter((name) => name !== server);
|
|
64
|
-
// Drop the key entirely when empty (exactOptionalPropertyTypes; a
|
|
65
|
-
// written `mcps: undefined` would also misrepresent the definition).
|
|
66
|
-
const rest = { ...definition };
|
|
67
|
-
delete rest.mcps;
|
|
68
|
-
const updated: ProfileDefinition = next.length > 0 ? { ...rest, mcps: next } : rest;
|
|
69
|
-
await catalogStore(input, scope).upsert(input.profile.name, updated);
|
|
70
|
-
return { mcps: next, changed: true };
|
|
71
|
-
}
|