@ian-pascoe/pi-minimal-subagents 0.19.0 → 0.19.2

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 CHANGED
@@ -252,9 +252,17 @@ still apply; the saved grant is not silently rewritten. Toolsets configure names
252
252
  not tool operations: granting a multifunction tool grants that tool's available
253
253
  operations, regardless of preset name.
254
254
 
255
- Child Agents load Pi's built-in `llama.cpp`, `codemode`, `tool-search`, and `mcp`
256
- extensions unless settings disable them (`-builtin:<name>`); each child connects
257
- its own MCP servers. Pi does not export the llama.cpp factory, so children load it
255
+ Child Agents load Pi's built-in `llama.cpp`, `codemode`, and `tool-search`
256
+ extensions unless settings disable them (`-builtin:<name>`). They load the `mcp`
257
+ extension, under the same setting, only when the Launch Contract grants an MCP tool
258
+ (`mcp__<server>__<tool>`) or an MCP resource tool (`list_mcp_resources`,
259
+ `list_mcp_resource_templates`, `read_mcp_resource`). Each such child connects
260
+ its own copy of the `mcp.json` servers it can reach: the servers of its granted
261
+ MCP tools, or every server when it is granted a resource tool. Servers that other
262
+ extensions register with `pi.registerMcpServer()` still all connect. Pi does not
263
+ export its `mcp.json` loader, so children load it from Pi's installed
264
+ `dist/extensions/mcp/config.js`; when that file is absent, a child with an MCP
265
+ grant connects every server. Pi does not export the llama.cpp factory, so children load it
258
266
  from Pi's installed `dist/extensions/llama/index.js`; when that file is absent, a
259
267
  llama.cpp launch model is reported as a missing dependency. Child `codemode`
260
268
  scripts get no `models` API, so they cannot call models outside the Launch
@@ -262,7 +270,8 @@ Contract. A tool's exposure still decides whether it is declared or reachable on
262
270
  through `codemode` or `tool_search`. Granted `codemode`- or `deferred`-exposed
263
271
  tools are callable but not declared, so a child that needs them also needs
264
272
  `codemode` or `tool_search` in its grant. Ungranted tools are unreachable in the
265
- child. MCP tools are checked at launch only by name, because children register
273
+ child, including MCP tools: `codemode` and `tool_search` find and call only the
274
+ MCP tools the Launch Contract names. MCP tools are checked at launch only by name, because children register
266
275
  them after connecting; a tool whose server disappeared fails when called. Closing
267
276
  a child runtime shuts its extensions down, closing its MCP connections. A reopened
268
277
  child re-declares the tools it last declared itself, including tools loaded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -31,7 +31,7 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@ian-pascoe/pi-utils": "^0.7.0",
34
+ "@ian-pascoe/pi-utils": "^0.7.1",
35
35
  "minimatch": "^10.2.6"
36
36
  },
37
37
  "peerDependencies": {
@@ -39,7 +39,7 @@
39
39
  "@earendil-works/pi-ai": ">=1.1.0",
40
40
  "@earendil-works/pi-coding-agent": ">=1.1.0",
41
41
  "@earendil-works/pi-tui": ">=1.1.0",
42
- "typebox": "*"
42
+ "typebox": ">=1"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=22.19.0"
@@ -6,8 +6,10 @@ import {
6
6
  createMcpExtension,
7
7
  createToolSearchExtension,
8
8
  DefaultResourceLoader,
9
+ type ExtensionContext,
9
10
  type ExtensionFactory,
10
11
  getPackageDir,
12
+ type LoadedMcpConfig,
11
13
  SettingsManager,
12
14
  } from "@earendil-works/pi-coding-agent";
13
15
  import { Type } from "typebox";
@@ -22,6 +24,8 @@ export interface ChildResourceLoaderOptions {
22
24
  extensionEntrypoint: string;
23
25
  systemPromptBlock: string;
24
26
  settingsManager?: SettingsManager;
27
+ /** The Launch Contract's ordinary tools, which decide the MCP servers the child connects. */
28
+ grantedTools: readonly string[];
25
29
  }
26
30
 
27
31
  type InlineExtension = NonNullable<
@@ -61,8 +65,84 @@ export function childProviderAvailable(provider: string): boolean {
61
65
  /** Child scripts must not call models outside their Launch Contract. */
62
66
  export const CHILD_CODEMODE_MODELS = false;
63
67
 
68
+ const MCP_RESOURCE_TOOLS = new Set([
69
+ "list_mcp_resources",
70
+ "list_mcp_resource_templates",
71
+ "read_mcp_resource",
72
+ ]);
73
+
74
+ /** Whether a tool comes from MCP: a server tool (`mcp__<server>__<tool>`) or a resource tool. */
75
+ export function isMcpToolName(toolName: string): boolean {
76
+ return toolName.startsWith("mcp__") || MCP_RESOURCE_TOOLS.has(toolName);
77
+ }
78
+
79
+ /** Which `mcp.json` servers a grant reaches, by server name. */
80
+ export type McpServerScope = "none" | "all" | ((server: string) => boolean);
81
+
82
+ /**
83
+ * The servers a Child Agent's granted tools can reach. A resource tool or a pattern reaches every
84
+ * server; an `mcp__<namespace>__<tool>` name reaches its server, named as in Pi's `mcpNamespace`.
85
+ */
86
+ export function childMcpServerScope(grantedTools: readonly string[]): McpServerScope {
87
+ const mcpTools = grantedTools.filter(isMcpToolName);
88
+ if (mcpTools.length === 0) return "none";
89
+ if (mcpTools.some((name) => MCP_RESOURCE_TOOLS.has(name) || name.includes("*"))) return "all";
90
+ return (server) => {
91
+ const prefix = `mcp__${server.replace(/-/g, "_")}__`;
92
+ return mcpTools.some((name) => name.startsWith(prefix));
93
+ };
94
+ }
95
+
96
+ type LoadMcpConfig = (options: {
97
+ agentDir: string;
98
+ cwd: string;
99
+ projectTrusted: boolean;
100
+ }) => LoadedMcpConfig;
101
+
102
+ const McpConfigModuleSchema = Type.Object({
103
+ loadMcpConfig: Type.Function([Type.Unknown()], Type.Unknown()),
104
+ });
105
+
106
+ /** Pi's `mcp.json` loader, which Pi does not export; undefined when its file is absent. */
107
+ async function piLoadMcpConfig(): Promise<LoadMcpConfig | undefined> {
108
+ const path = join(getPackageDir(), "dist", "extensions", "mcp", "config.js");
109
+ if (!existsSync(path)) return undefined;
110
+ const module: unknown = await import(pathToFileURL(path).href);
111
+ if (!Value.Check(McpConfigModuleSchema, module)) return undefined;
112
+ // SAFETY: the installed Pi's own `loadMcpConfig`, which returns its `LoadedMcpConfig`.
113
+ return module.loadMcpConfig as LoadMcpConfig;
114
+ }
115
+
116
+ /**
117
+ * Pi's MCP extension limited to the servers in `scope`, so a child starts no server it cannot
118
+ * reach. Without Pi's loader it connects every server, as the root does.
119
+ */
120
+ function mcpExtension(scope: McpServerScope, agentDir: string): InlineExtension[] {
121
+ if (scope === "none") return [];
122
+ const builtin = (factory: ExtensionFactory): InlineExtension[] => [
123
+ { name: "mcp", factory, builtin: true, replaceable: true },
124
+ ];
125
+ if (scope === "all") return builtin(createMcpExtension());
126
+ return builtin(async (pi) => {
127
+ const loadMcpConfig = await piLoadMcpConfig();
128
+ if (!loadMcpConfig) return createMcpExtension()(pi);
129
+ const loadConfig = (ctx: ExtensionContext) => {
130
+ const loaded = loadMcpConfig({
131
+ agentDir,
132
+ cwd: ctx.cwd,
133
+ projectTrusted: ctx.isProjectTrusted(),
134
+ });
135
+ return { ...loaded, servers: loaded.servers.filter(({ name }) => scope(name)) };
136
+ };
137
+ return createMcpExtension({ loadConfig })(pi);
138
+ });
139
+ }
140
+
64
141
  /** Pi's built-in extensions in Pi's order, so children match the root's built-in surface. */
65
- export function childBuiltinExtensions(): InlineExtension[] {
142
+ export function childBuiltinExtensions(
143
+ grantedTools: readonly string[],
144
+ agentDir: string,
145
+ ): InlineExtension[] {
66
146
  return [
67
147
  ...llamaExtension(),
68
148
  {
@@ -72,7 +152,7 @@ export function childBuiltinExtensions(): InlineExtension[] {
72
152
  replaceable: true,
73
153
  },
74
154
  { name: "tool-search", factory: createToolSearchExtension(), builtin: true, replaceable: true },
75
- { name: "mcp", factory: createMcpExtension(), builtin: true, replaceable: true },
155
+ ...mcpExtension(childMcpServerScope(grantedTools), agentDir),
76
156
  ];
77
157
  }
78
158
 
@@ -88,7 +168,7 @@ function createDefaultChildResourceLoaderOptions(
88
168
  noContextFiles: false,
89
169
  noSkills: false,
90
170
  noPromptTemplates: false,
91
- extensionFactories: childBuiltinExtensions(),
171
+ extensionFactories: childBuiltinExtensions(input.grantedTools, input.agentDir),
92
172
  extensionsOverride: (base) => ({
93
173
  ...base,
94
174
  extensions: base.extensions.filter(
@@ -44,6 +44,7 @@ import {
44
44
  CHILD_CODEMODE_MODELS,
45
45
  childProviderAvailable,
46
46
  createChildResourceLoader,
47
+ isMcpToolName,
47
48
  } from "./minimal-subagents-child-resources.js";
48
49
  import {
49
50
  CHILD_IDENTITY_ENTRY_TYPE,
@@ -108,17 +109,12 @@ export interface RuntimeToolAdapter {
108
109
  readonly replacements: readonly RuntimeToolReplacement[];
109
110
  }
110
111
 
111
- const MCP_RESOURCE_TOOL_NAMES = new Set([
112
- "list_mcp_resources",
113
- "list_mcp_resource_templates",
114
- "read_mcp_resource",
115
- ]);
116
-
117
- // ponytail: Pi's built-in MCP registers tools only after connecting, so launch checks accept its
118
- // tool names unverified; a vanished server's tools fail when called instead of at launch.
119
- function isMcpToolName(toolName: string): boolean {
120
- return toolName.startsWith("mcp__") || MCP_RESOURCE_TOOL_NAMES.has(toolName);
121
- }
112
+ /**
113
+ * Pi filters MCP tools by a `tools` allowlist only when it names one (`mcp__` prefix); otherwise
114
+ * every MCP tool stays callable from codemode and tool_search. This entry makes the child's
115
+ * allowlist always filter them, and matches no tool: MCP tools are `mcp__<server>__<tool>`.
116
+ */
117
+ const MCP_ALLOWLIST_SENTINEL = "mcp__";
122
118
 
123
119
  /** Resolve the exact active tools permitted by one Child Agent Launch Contract. */
124
120
  export function resolveChildActiveToolNames(
@@ -1205,10 +1201,13 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1205
1201
  extensionEntrypoint: this.options.extensionEntrypoint,
1206
1202
  systemPromptBlock: this.buildChildSystemPrompt(agent),
1207
1203
  settingsManager,
1204
+ grantedTools: agent.launch_contract.ordinary_tools,
1208
1205
  });
1209
1206
  await resourceLoader.reload();
1210
1207
  for (const extension of resourceLoader.getExtensions().extensions) {
1211
1208
  for (const toolName of extension.tools.keys()) names.add(toolName);
1209
+ // ponytail: Pi's built-in MCP registers tools only after connecting, so launch checks
1210
+ // accept its tool names unverified; a vanished server's tools fail when called instead.
1212
1211
  if (extension.path === "builtin:mcp") {
1213
1212
  for (const toolName of agent.launch_contract.ordinary_tools) {
1214
1213
  if (isMcpToolName(toolName)) names.add(toolName);
@@ -1241,6 +1240,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1241
1240
  extensionEntrypoint: this.options.extensionEntrypoint,
1242
1241
  systemPromptBlock: this.buildChildSystemPrompt(agent),
1243
1242
  settingsManager,
1243
+ grantedTools: agent.launch_contract.ordinary_tools,
1244
1244
  });
1245
1245
  await resourceLoader.reload();
1246
1246
  const modelRuntime = await ModelRuntime.create({
@@ -1266,7 +1266,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1266
1266
  agentDir: this.options.agentDir,
1267
1267
  model,
1268
1268
  thinkingLevel: agent.launch_contract.thinking_level,
1269
- tools: [...allowedToolNames, ...adapterToolNames],
1269
+ tools: [...allowedToolNames, ...adapterToolNames, MCP_ALLOWLIST_SENTINEL],
1270
1270
  customTools: coordinatorTools,
1271
1271
  resourceLoader,
1272
1272
  sessionManager,