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.
@@ -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 { ActivationPlan } from "./profile-resolver.ts";
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.mcps === undefined) {
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 { servers, sharedServers, projectServers, baseConfig } = await loadMergedMcpServers(
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
- const allowedSet = new Set(plan.mcps);
451
- const filteredServers: Record<string, unknown> = {};
452
-
453
- for (const serverName of plan.mcps) {
454
- if (servers[serverName] !== undefined) {
455
- const def = { ...servers[serverName] };
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)