pi-profile-switch 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -22
- package/README.zh-CN.md +14 -22
- package/examples/example.json +8 -4
- package/extensions/pi-profile/index.ts +76 -6
- package/package.json +2 -1
- package/schemas/profiles.schema.json +15 -1
- package/skills/profile-config/SKILL.md +16 -7
- package/src/launcher/initial-profile.ts +22 -9
- package/src/mcp-config.ts +24 -12
- package/src/profile-catalog.ts +47 -0
- package/src/profile-resolver.ts +190 -0
- package/src/settings-generator.ts +19 -31
- package/src/startup-notifier.ts +636 -0
- package/src/switching/apply-plan.ts +113 -18
- package/src/switching/status.ts +66 -9
- package/src/switching/tool-references.ts +26 -4
package/src/profile-resolver.ts
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
import { minimatch } from "minimatch";
|
|
28
28
|
|
|
29
29
|
import type { DiscoveredExtensions } from "./extension-discovery.ts";
|
|
30
|
+
import { isRecord } from "./json-file.ts";
|
|
31
|
+
import { isAdapterExtension, type MergedMcpResult } from "./mcp-config.ts";
|
|
30
32
|
import type { ProfileDefinition, ProfileModel, ProfileSource, ResolvedProfile } from "./profile-catalog.ts";
|
|
31
33
|
|
|
32
34
|
/** Extracts a ProfileModel from flat definition keys, if declared. */
|
|
@@ -85,6 +87,10 @@ export interface ActivationPlan {
|
|
|
85
87
|
* surfaced in `/profile status`; undefined when the profile declares
|
|
86
88
|
* no `mcps` (no restriction). */
|
|
87
89
|
mcps?: string[];
|
|
90
|
+
/** Per-server MCP tool policy: literal adapter selectors per server. */
|
|
91
|
+
mcpTools?: Record<string, string[]>;
|
|
92
|
+
/** Prepared in-memory instance mcp.json config; never written to pi-profile.json */
|
|
93
|
+
instanceMcpConfig?: Record<string, unknown>;
|
|
88
94
|
/** Glob references (skills/extensions/MCP) that matched nothing this
|
|
89
95
|
* resolution — surfaced as warnings so zero-match typos are never silent.
|
|
90
96
|
* Tool globs are excluded: extension-contributed tools are unknowable
|
|
@@ -111,6 +117,11 @@ export interface ResolveInput {
|
|
|
111
117
|
* fails rather than passing references through unchecked.
|
|
112
118
|
*/
|
|
113
119
|
discoveredMcpServers?: string[];
|
|
120
|
+
/**
|
|
121
|
+
* Merged MCP configuration result. Carries server definitions, ownership,
|
|
122
|
+
* and base settings.
|
|
123
|
+
*/
|
|
124
|
+
mcpDiscovery?: MergedMcpResult;
|
|
114
125
|
/**
|
|
115
126
|
* The runtime overlay (ticket 06): temporary narrowing applied on top of
|
|
116
127
|
* the profile definition at every resolution. Overlay references must
|
|
@@ -213,6 +224,10 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
213
224
|
const definition: ProfileDefinition = profile.definition;
|
|
214
225
|
const unmatched: string[] = [];
|
|
215
226
|
|
|
227
|
+
if (input.mcpDiscovery !== undefined && input.discoveredMcpServers === undefined) {
|
|
228
|
+
input.discoveredMcpServers = Object.keys(input.mcpDiscovery.servers).sort();
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
let mcps: string[] | undefined;
|
|
217
232
|
if (definition.mcps !== undefined && definition.mcps.length > 0) {
|
|
218
233
|
if (input.discoveredMcpServers === undefined) {
|
|
@@ -286,6 +301,67 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
286
301
|
}
|
|
287
302
|
}
|
|
288
303
|
|
|
304
|
+
const mcpToolsDef = (definition as ProfileDefinition & { mcp_tools?: Record<string, string[]> }).mcp_tools;
|
|
305
|
+
const mcpToolKeys = mcpToolsDef !== undefined ? Object.keys(mcpToolsDef) : [];
|
|
306
|
+
|
|
307
|
+
let mcpTools: Record<string, string[]> | undefined;
|
|
308
|
+
if (mcpToolKeys.length > 0) {
|
|
309
|
+
const hasAdapter = planExtensions.some((entry) => isAdapterExtension(entry));
|
|
310
|
+
if (!hasAdapter) {
|
|
311
|
+
throw new ActivationError(
|
|
312
|
+
`profile "${profile.name}" declares MCP tools but pi-mcp-adapter is not active. ` +
|
|
313
|
+
`Select the adapter in the profile's extensions (e.g. via its npm package) or remove the "mcp_tools" declaration.`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (input.mcpDiscovery === undefined) {
|
|
317
|
+
throw new ActivationError(
|
|
318
|
+
`profile "${profile.name}" declares MCP tools but no adapter server discovery is available`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const mcpDiscovery = input.mcpDiscovery;
|
|
323
|
+
const usableCandidates = Object.keys(mcpDiscovery.servers)
|
|
324
|
+
.filter((s) => {
|
|
325
|
+
const isUser =
|
|
326
|
+
Object.hasOwn(mcpDiscovery.serverOwners, s) &&
|
|
327
|
+
mcpDiscovery.serverOwners[s] === "user" &&
|
|
328
|
+
!mcpDiscovery.projectServers.has(s);
|
|
329
|
+
const isEnabled = mcpDiscovery.servers[s]?.disabled !== true;
|
|
330
|
+
const isAllowedByMcps = mcps === undefined || mcps.includes(s);
|
|
331
|
+
return isUser && isEnabled && isAllowedByMcps;
|
|
332
|
+
})
|
|
333
|
+
.sort();
|
|
334
|
+
const candidateMsg = usableCandidates.length > 0 ? ` (usable candidates: ${usableCandidates.join(", ")})` : "";
|
|
335
|
+
|
|
336
|
+
for (const serverKey of mcpToolKeys) {
|
|
337
|
+
if (!Object.hasOwn(mcpDiscovery.servers, serverKey)) {
|
|
338
|
+
throw new ActivationError(`profile "${profile.name}": unknown MCP server "${serverKey}"${candidateMsg}`);
|
|
339
|
+
}
|
|
340
|
+
const isProject =
|
|
341
|
+
mcpDiscovery.serverOwners[serverKey] === "project" || mcpDiscovery.projectServers.has(serverKey);
|
|
342
|
+
if (isProject) {
|
|
343
|
+
throw new ActivationError(
|
|
344
|
+
`profile "${profile.name}": cannot narrow project-level MCP server "${serverKey}" (project-level servers are outside profile narrowing)`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
const isDisabled =
|
|
348
|
+
mcpDiscovery.servers[serverKey]?.disabled === true ||
|
|
349
|
+
(mcps !== undefined && !mcps.includes(serverKey));
|
|
350
|
+
if (isDisabled) {
|
|
351
|
+
throw new ActivationError(`profile "${profile.name}": MCP server "${serverKey}" is disabled${candidateMsg}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
mcpTools = Object.fromEntries(
|
|
356
|
+
Object.entries(mcpToolsDef ?? {}).map(([server, selectors]) => [server, [...selectors]]),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let instanceMcpConfig: Record<string, unknown> | undefined;
|
|
361
|
+
if (input.mcpDiscovery !== undefined && (mcps !== undefined || mcpToolKeys.length > 0)) {
|
|
362
|
+
instanceMcpConfig = buildInstanceMcpConfig(profile.name, input.mcpDiscovery, mcps, mcpTools);
|
|
363
|
+
}
|
|
364
|
+
|
|
289
365
|
let tools: string[] | undefined;
|
|
290
366
|
if (toolReferences !== undefined) {
|
|
291
367
|
tools = expandReferences(toolReferences, BUILTIN_TOOL_NAMES, (name) => name, "tool", {
|
|
@@ -357,6 +433,120 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
|
|
|
357
433
|
...(model !== undefined ? { model } : {}),
|
|
358
434
|
...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
|
|
359
435
|
...(mcps !== undefined ? { mcps } : {}),
|
|
436
|
+
...(mcpTools !== undefined ? { mcpTools } : {}),
|
|
437
|
+
...(instanceMcpConfig !== undefined ? { instanceMcpConfig } : {}),
|
|
360
438
|
...(unmatched.length > 0 ? { unmatched } : {}),
|
|
361
439
|
};
|
|
362
440
|
}
|
|
441
|
+
|
|
442
|
+
function computeSafeMcpToolIntersection(
|
|
443
|
+
profileName: string,
|
|
444
|
+
serverName: string,
|
|
445
|
+
requestedTools: string[],
|
|
446
|
+
existingIncludes?: string[],
|
|
447
|
+
): string[] {
|
|
448
|
+
if (
|
|
449
|
+
requestedTools.length === 0 ||
|
|
450
|
+
existingIncludes === undefined ||
|
|
451
|
+
existingIncludes.length === 0 ||
|
|
452
|
+
existingIncludes.includes("*")
|
|
453
|
+
) {
|
|
454
|
+
return [...requestedTools];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const unsafeSelector = requestedTools.find((selector) => !existingIncludes.includes(selector));
|
|
458
|
+
if (unsafeSelector !== undefined) {
|
|
459
|
+
throw new ActivationError(
|
|
460
|
+
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": ` +
|
|
461
|
+
`selector ${JSON.stringify(unsafeSelector)} is not an exact member of existing includeTools; ` +
|
|
462
|
+
`use identical literals or an existing "*" allowlist to prove the restriction is safe`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return [...requestedTools];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function setOwnRecordValue<T>(record: Record<string, T>, key: string, value: T): void {
|
|
469
|
+
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function buildInstanceMcpConfig(
|
|
473
|
+
profileName: string,
|
|
474
|
+
mcpDiscovery: MergedMcpResult,
|
|
475
|
+
mcps?: string[],
|
|
476
|
+
mcpTools?: Record<string, string[]>,
|
|
477
|
+
): Record<string, unknown> {
|
|
478
|
+
const filteredServers: Record<string, Record<string, unknown>> = {};
|
|
479
|
+
const userServers = Object.keys(mcpDiscovery.servers).filter(
|
|
480
|
+
(s) =>
|
|
481
|
+
Object.hasOwn(mcpDiscovery.serverOwners, s) &&
|
|
482
|
+
mcpDiscovery.serverOwners[s] === "user" &&
|
|
483
|
+
!mcpDiscovery.projectServers.has(s),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const serversToInclude = mcps !== undefined ? mcps : userServers;
|
|
487
|
+
for (const serverName of serversToInclude) {
|
|
488
|
+
const originalDef = Object.hasOwn(mcpDiscovery.servers, serverName)
|
|
489
|
+
? mcpDiscovery.servers[serverName]
|
|
490
|
+
: undefined;
|
|
491
|
+
if (originalDef !== undefined) {
|
|
492
|
+
const def = { ...originalDef };
|
|
493
|
+
if (mcps !== undefined) {
|
|
494
|
+
delete def.disabled;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (mcpTools && Object.hasOwn(mcpTools, serverName)) {
|
|
498
|
+
if (
|
|
499
|
+
def.includeTools !== undefined &&
|
|
500
|
+
(!Array.isArray(def.includeTools) || !def.includeTools.every((t) => typeof t === "string"))
|
|
501
|
+
) {
|
|
502
|
+
throw new ActivationError(
|
|
503
|
+
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing includeTools`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (
|
|
507
|
+
def.excludeTools !== undefined &&
|
|
508
|
+
(!Array.isArray(def.excludeTools) || !def.excludeTools.every((t) => typeof t === "string"))
|
|
509
|
+
) {
|
|
510
|
+
throw new ActivationError(
|
|
511
|
+
`profile "${profileName}": unsafe MCP tool filter intersection for server "${serverName}": invalid existing excludeTools`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const requested = mcpTools[serverName];
|
|
516
|
+
const existingIncludes = Array.isArray(def.includeTools) ? (def.includeTools as string[]) : undefined;
|
|
517
|
+
const existingExcludes = Array.isArray(def.excludeTools) ? (def.excludeTools as string[]) : undefined;
|
|
518
|
+
|
|
519
|
+
const safeTools = computeSafeMcpToolIntersection(
|
|
520
|
+
profileName,
|
|
521
|
+
serverName,
|
|
522
|
+
requested,
|
|
523
|
+
existingIncludes,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
if (safeTools.length === 0) {
|
|
527
|
+
delete def.includeTools;
|
|
528
|
+
if (existingExcludes === undefined || !existingExcludes.includes("*")) {
|
|
529
|
+
def.excludeTools = [...(existingExcludes ?? []), "*"];
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
def.includeTools = safeTools;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
setOwnRecordValue(filteredServers, serverName, def);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (mcps !== undefined) {
|
|
541
|
+
for (const sharedName of mcpDiscovery.sharedServers) {
|
|
542
|
+
if (!mcps.includes(sharedName)) {
|
|
543
|
+
if (mcpDiscovery.projectServers.has(sharedName)) continue;
|
|
544
|
+
setOwnRecordValue(filteredServers, sharedName, { disabled: true });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return isRecord(mcpDiscovery.baseConfig)
|
|
550
|
+
? { ...mcpDiscovery.baseConfig, mcpServers: filteredServers }
|
|
551
|
+
: { mcpServers: filteredServers };
|
|
552
|
+
}
|
|
@@ -45,7 +45,7 @@ import { mkdir, mkdtemp, lstat, readdir, readFile, readlink, rm, stat, symlink,
|
|
|
45
45
|
import { homedir } from "node:os";
|
|
46
46
|
import path from "node:path";
|
|
47
47
|
|
|
48
|
-
import type
|
|
48
|
+
import { buildInstanceMcpConfig, type ActivationPlan } from "./profile-resolver.ts";
|
|
49
49
|
import { getInstancesRootDir } from "./workspace.ts";
|
|
50
50
|
import { isRecord } from "./json-file.ts";
|
|
51
51
|
import { loadMergedMcpServers } from "./mcp-config.ts";
|
|
@@ -403,6 +403,7 @@ export async function writeRuntimeFiles(
|
|
|
403
403
|
...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
|
|
404
404
|
...(plan.disabledTools !== undefined ? { disabledTools: plan.disabledTools } : {}),
|
|
405
405
|
...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
|
|
406
|
+
...(plan.mcpTools !== undefined ? { mcpTools: plan.mcpTools } : {}),
|
|
406
407
|
// The resolved sets feed /profile status (absolute paths) and the
|
|
407
408
|
// glob-delta diff against the previous activation.
|
|
408
409
|
resolved: {
|
|
@@ -429,49 +430,36 @@ export async function writeRuntimeFiles(
|
|
|
429
430
|
await symlink(path.join(options.agentDir, "trust.json"), trustLink);
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
// MCP Servers generation (Ticket 04)
|
|
433
|
+
// MCP Servers generation (Ticket 04 & Separate MCP tool filtering)
|
|
433
434
|
const mcpTarget = path.join(options.agentDir, "mcp.json");
|
|
434
435
|
const mcpInstancePath = path.join(runtimeDir, "mcp.json");
|
|
435
|
-
if (plan.
|
|
436
|
+
if (plan.instanceMcpConfig !== undefined) {
|
|
437
|
+
try { await rm(mcpInstancePath); } catch {}
|
|
438
|
+
await writeFile(mcpInstancePath, JSON.stringify(plan.instanceMcpConfig, null, 2));
|
|
439
|
+
} else if (
|
|
440
|
+
plan.mcps === undefined &&
|
|
441
|
+
(plan.mcpTools === undefined || Object.keys(plan.mcpTools).length === 0)
|
|
442
|
+
) {
|
|
436
443
|
// No restrictions, symlink
|
|
437
444
|
if (await exists(mcpTarget)) {
|
|
438
445
|
try { await rm(mcpInstancePath); } catch {}
|
|
439
446
|
await symlink(mcpTarget, mcpInstancePath);
|
|
440
447
|
}
|
|
441
448
|
} else {
|
|
442
|
-
// Filter MCP servers
|
|
449
|
+
// Filter MCP servers (fallback when instanceMcpConfig is not pre-populated)
|
|
443
450
|
try { await rm(mcpInstancePath); } catch {}
|
|
444
|
-
const
|
|
451
|
+
const discovery = await loadMergedMcpServers(
|
|
445
452
|
options.agentDir,
|
|
446
453
|
options.projectDir,
|
|
447
454
|
options.homeDir !== undefined ? { homeDir: options.homeDir } : undefined,
|
|
448
455
|
);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
delete def.disabled;
|
|
457
|
-
filteredServers[serverName] = def;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
for (const sharedName of sharedServers) {
|
|
462
|
-
if (!allowedSet.has(sharedName)) {
|
|
463
|
-
// Project-level servers are not the profile's to narrow (the same
|
|
464
|
-
// boundary as project skills and extensions).
|
|
465
|
-
if (projectServers.has(sharedName)) continue;
|
|
466
|
-
filteredServers[sharedName] = { disabled: true };
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const outputConfig: Record<string, unknown> = isRecord(baseConfig)
|
|
471
|
-
? { ...baseConfig, mcpServers: filteredServers }
|
|
472
|
-
: { mcpServers: filteredServers };
|
|
473
|
-
|
|
474
|
-
await writeFile(mcpInstancePath, JSON.stringify(outputConfig, null, 2));
|
|
456
|
+
const instanceMcpConfig = buildInstanceMcpConfig(
|
|
457
|
+
plan.profile,
|
|
458
|
+
discovery,
|
|
459
|
+
plan.mcps,
|
|
460
|
+
plan.mcpTools,
|
|
461
|
+
);
|
|
462
|
+
await writeFile(mcpInstancePath, JSON.stringify(instanceMcpConfig, null, 2));
|
|
475
463
|
}
|
|
476
464
|
|
|
477
465
|
// Instructions generation (Ticket 04)
|