diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 2f6ba5363703a..693765b3938e8 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -165,9 +165,12 @@ T3_MCP_BEARER_TOKEN= ``` The extension preserves public names under `mcp__t3-code__` for saved loadouts -and tool selectors. Modern Pi also receives hidden `mcp__t3_code__` aliases, -which reserve the normalized namespace against configured MCP servers without -adding declarations or search results. On Pi 0.99+, +and tool selectors. Modern Pi also receives `mcp__t3_code__` aliases, Pi's own +name for the server, which reserve it against configured MCP servers. They are +`model-only` and hide their own declarations, so the model can call them without +added declarations, search results, or codemode entries. An alias follows its +public tool: dropped when that tool is excluded, and off while a direct one is +off. Pi's `hidden` exposure would make them unreachable. On Pi 0.99+, `orchestrator_capabilities`, `delegate_task`, and `task_status` remain directly available; optional tools are discovered through Pi's builtin `tool_search`. On older Pi or without builtin search, all tools remain directly available. diff --git a/packages/provider-pi/src/server/mcpBridge.testkit.ts b/packages/provider-pi/src/server/mcpBridge.testkit.ts index 505abd4735ff9..4f6c1076117d2 100644 --- a/packages/provider-pi/src/server/mcpBridge.testkit.ts +++ b/packages/provider-pi/src/server/mcpBridge.testkit.ts @@ -12,6 +12,12 @@ interface RegisteredTool { readonly exposure?: string; readonly promptSnippet?: string; readonly promptGuidelines?: ReadonlyArray; + readonly prepareLoadout?: (loadout: { + readonly declared: ReadonlyArray; + readonly callable: ReadonlyArray; + readonly registered: ReadonlyArray; + readonly getExposure: (name: string) => string; + }) => { readonly hiddenDeclarations?: ReadonlyArray } | undefined; readonly execute: ( id: string, args: Record, @@ -39,12 +45,20 @@ type AgentStartHook = ( }, ) => Promise<{ systemPrompt: string } | undefined>; +function activatesOnRegistration(tool: RegisteredTool) { + return ( + tool.exposure === undefined || tool.exposure === "direct" || tool.exposure === "model-only" + ); +} + export async function loadMcpBridge( options: { readonly modern?: boolean; readonly result?: unknown; readonly toolSearchAvailable?: boolean; readonly toolSearchDisabled?: boolean; + // Pi activates direct and model-only tools when they are registered. + readonly activatesOnRegistration?: boolean; readonly allowsTool?: (name: string) => boolean; readonly runtimeMode?: string; readonly inputSchema?: Record; @@ -129,8 +143,17 @@ export async function loadMcpBridge( registerTool: (tool: RegisteredTool) => { if (options.allowsTool && !options.allowsTool(tool.name)) return; const index = tools.findIndex((current) => current.name === tool.name); + const previous = index === -1 ? undefined : tools[index]; if (index === -1) tools.push(tool); else tools[index] = tool; + if ( + options.activatesOnRegistration && + activatesOnRegistration(tool) && + (previous === undefined || !activatesOnRegistration(previous)) && + !activeTools.includes(tool.name) + ) { + activeTools = [...activeTools, tool.name]; + } }, getActiveTools: () => activeTools, setActiveTools: (names: string[]) => { @@ -153,9 +176,43 @@ export async function loadMcpBridge( : {}), }, }); + const exposureOf = (name: string) => + tools.find((tool) => tool.name === name)?.exposure ?? "direct"; + // Pi resolves model tool calls against the active, non-hidden tools only. + const modelTools = () => + tools.filter((tool) => activeTools.includes(tool.name) && exposureOf(tool.name) !== "hidden"); + // Pi's ctx.executeTool() catalog, which codemode scripts search and list. + const callableTools = () => + tools.filter( + (tool) => + ["codemode", "deferred"].includes(exposureOf(tool.name)) || + (exposureOf(tool.name) === "direct" && activeTools.includes(tool.name)), + ); + const declaredTools = () => { + const active = modelTools(); + const loadout = { + declared: active, + callable: callableTools(), + registered: tools, + getExposure: exposureOf, + }; + const hidden = new Set( + active.flatMap((tool) => tool.prepareLoadout?.(loadout)?.hiddenDeclarations ?? []), + ); + return active.map((tool) => tool.name).filter((name) => !hidden.has(name)); + }; return { handlers, tools, + declaredTools, + callableTools: () => callableTools().map((tool) => tool.name), + callModelTool: async (name: string, args: Record) => { + const tool = modelTools().find((candidate) => candidate.name === name); + if (tool === undefined) { + return { content: [{ type: "text", text: `Tool ${name} not found` }], isError: true }; + } + return tool.execute("model-call", args); + }, setBridgeSourcePath: (path: string) => { bridgeSourcePath = path; }, diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index 1e2ac44cf4b5c..aa21fea07c953 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -169,7 +169,11 @@ describe("Pi MCP tool exposure", () => { }; it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { - const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + activatesOnRegistration: true, + }); await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext); const start = bridge.handlers.get("before_agent_start"); assert.isDefined(start); @@ -177,10 +181,27 @@ describe("Pi MCP tool exposure", () => { assert.include(prompt?.systemPrompt ?? "", "orchestrator_capabilities"); assert.equal(bridge.servers.length, 0); assert.equal(bridge.tools.length, 8); - assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]); + const aliases = [ + "mcp__t3_code__orchestrator_capabilities", + "mcp__t3_code__delegate_task", + "mcp__t3_code__task_status", + "mcp__t3_code__preview_snapshot", + ]; + const publicDirect = [ + "mcp__t3-code__orchestrator_capabilities", + "mcp__t3-code__delegate_task", + "mcp__t3-code__task_status", + ]; + assert.sameMembers(bridge.getActiveTools(), [ + "read", + "tool_search", + ...publicDirect, + ...aliases, + ]); + assert.deepEqual(bridge.declaredTools(), publicDirect); assert.deepEqual( bridge.tools - .filter((tool) => tool.exposure !== "hidden") + .filter((tool) => tool.name.startsWith("mcp__t3-code__")) .map((tool) => [tool.name, tool.exposure]), [ ["mcp__t3-code__orchestrator_capabilities", "direct"], @@ -190,13 +211,10 @@ describe("Pi MCP tool exposure", () => { ], ); assert.deepEqual( - bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name), - [ - "mcp__t3_code__orchestrator_capabilities", - "mcp__t3_code__delegate_task", - "mcp__t3_code__task_status", - "mcp__t3_code__preview_snapshot", - ], + bridge.tools + .filter((tool) => tool.name.startsWith("mcp__t3_code__")) + .map((tool) => [tool.name, tool.exposure]), + aliases.map((name) => [name, "model-only"]), ); const result = await bridge.tools .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! @@ -210,9 +228,11 @@ describe("Pi MCP tool exposure", () => { modern: true, toolSearchAvailable: true, allowsTool: (name) => name !== "mcp__t3-code__delegate_task", + activatesOnRegistration: true, }); const event = { systemPrompt: "" }; await bridge.handlers.get("session_start")!(event, discoveryContext); + assert.notInclude(bridge.getActiveTools(), "mcp__t3_code__delegate_task"); const gatewayContext = { model: { api: "openai-completions" }, ui: { notify: assert.fail } }; await bridge.handlers.get("model_select")!(event, gatewayContext); bridge.restoreActiveTools(["read", "mcp__t3-code__task_status"]); @@ -223,6 +243,9 @@ describe("Pi MCP tool exposure", () => { "tool_search", "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__preview_snapshot", + "mcp__t3_code__orchestrator_capabilities", + "mcp__t3_code__task_status", + "mcp__t3_code__preview_snapshot", ]); await bridge.handlers.get("model_select")!(event, discoveryContext); assert.equal( @@ -232,6 +255,74 @@ describe("Pi MCP tool exposure", () => { assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot"); }); + it("keeps Pi's canonical MCP tool names callable by the model without declaring them twice", async () => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + activatesOnRegistration: true, + }); + const canonicalNames = [ + "mcp__t3_code__orchestrator_capabilities", + "mcp__t3_code__delegate_task", + "mcp__t3_code__task_status", + "mcp__t3_code__preview_snapshot", + ]; + const unreachableCanonicalNames = async () => { + const unreachable: string[] = []; + for (const name of canonicalNames) { + const result = await bridge.callModelTool(name, {}); + if (result.isError === true) unreachable.push(name); + } + return unreachable; + }; + const declaredCanonicalNames = () => + bridge.declaredTools().filter((name) => name.startsWith("mcp__t3_code__")); + const event = { systemPrompt: "" }; + + await bridge.handlers.get("session_start")!(event, discoveryContext); + assert.deepEqual( + await unreachableCanonicalNames(), + [], + "model calls to these names fail with Tool not found after session start", + ); + assert.deepEqual(declaredCanonicalNames(), [], "declared twice after session start"); + assert.equal(bridge.requests.at(-1)?.method, "tools/call"); + + bridge.restoreActiveTools([ + "read", + "mcp__t3-code__orchestrator_capabilities", + "mcp__t3-code__delegate_task", + "mcp__t3-code__task_status", + ]); + await bridge.handlers.get("session_tree")!(event, discoveryContext); + assert.deepEqual( + await unreachableCanonicalNames(), + [], + "model calls to these names fail with Tool not found after an older tree loadout", + ); + assert.deepEqual(declaredCanonicalNames(), [], "declared twice after an older tree loadout"); + assert.isFalse( + bridge.callableTools().some((name) => name.startsWith("mcp__t3_code__")), + "aliases duplicate the codemode catalog", + ); + + const requestCount = bridge.requests.length; + bridge.restoreActiveTools( + bridge.getActiveTools().filter((name) => name !== "mcp__t3-code__delegate_task"), + ); + const late = await bridge.callModelTool("mcp__t3_code__delegate_task", {}); + assert.isTrue(late.isError, "an alias runs after its public tool was switched off"); + assert.equal(bridge.requests.length, requestCount); + + bridge.restoreActiveTools(["read", "mcp__t3-code__task_status"]); + await bridge.handlers.get("session_tree")!(event, discoveryContext); + assert.deepEqual( + await unreachableCanonicalNames(), + ["mcp__t3_code__orchestrator_capabilities", "mcp__t3_code__delegate_task"], + "an alias stays callable while its direct public tool is off", + ); + }); + it.each(["legacy Pi", "disabled tool search"])( "keeps tool execution available with %s", async (mode) => { @@ -244,11 +335,16 @@ describe("Pi MCP tool exposure", () => { const start = bridge.handlers.get("session_start"); await start!({ systemPrompt: "Pi system prompt" }, discoveryContext); } - assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); + const publicTools = bridge.tools.filter((tool) => tool.name.startsWith("mcp__t3-code__")); + assert.equal(publicTools.length, 4); assert.isTrue( + publicTools.every((tool) => tool.exposure === undefined || tool.exposure === "direct"), + ); + assert.deepEqual( bridge.tools - .filter((tool) => tool.exposure !== "hidden") - .every((tool) => tool.exposure === undefined || tool.exposure === "direct"), + .filter((tool) => tool.name.startsWith("mcp__t3_code__")) + .map((tool) => tool.exposure), + mode === "legacy Pi" ? [] : ["model-only", "model-only", "model-only", "model-only"], ); const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); assert.isDefined(tool); diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index c63e60feb9e47..c580a1d096dec 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -385,31 +385,52 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // bridge as the credential owner: mcp.json overrides native registrations. const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function"; const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]); + const ALIAS_PREFIX = "mcp__t3_code__"; let deferOptionalTools = supportsExposure; + const isDeferred = (name: string) => deferOptionalTools && !directTools.has(name); + // An alias follows its public tool: gone when --exclude-tools removed it, + // off while a direct one is off. + const followsPublicTool = ( + name: string, + registered: ReadonlySet, + active: ReadonlyArray, + ) => + registered.has("mcp__t3-code__" + name) && + (isDeferred(name) || active.includes("mcp__t3-code__" + name)); let catalog: ReadonlyArray = []; const client = createMcpClient(endpoint, token); let started: Promise | undefined; const registerTools = () => { - // Preserve public names for saved loadouts and tool selectors. Hidden - // canonical names reserve ownership against Pi's configured MCP servers. - const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"]; + // Preserve public names for saved loadouts and tool selectors. The alias, + // Pi's own MCP name for T3, reserves it against Pi's configured MCP servers. + // model-only keeps it out of codemode; it hides its own declaration. + const prefixes = supportsExposure ? ["mcp__t3-code__", ALIAS_PREFIX] : ["mcp__t3-code__"]; readOnlyMcpTools.clear(); for (const tool of catalog) { const name = tool.name; for (const prefix of prefixes) { - if (tool.annotations?.readOnlyHint === true) readOnlyMcpTools.add(\`\${prefix}\${name}\`); - const exposure = prefix === "mcp__t3_code__" ? "hidden" : - deferOptionalTools && !directTools.has(name) ? "deferred" : "direct"; + const toolName = \`\${prefix}\${name}\`; + if (tool.annotations?.readOnlyHint === true) readOnlyMcpTools.add(toolName); + const isAlias = prefix === ALIAS_PREFIX; + const exposure = isAlias ? "model-only" : isDeferred(name) ? "deferred" : "direct"; pi.registerTool({ - name: \`\${prefix}\${name}\`, + name: toolName, label: name, description: tool.description ?? name, parameters: jsonSchemaToTypebox(tool.inputSchema), ...(supportsExposure ? { outputSchema: mcpOutputSchema(tool) } : {}), ...(supportsExposure ? { exposure } : {}), + ...(isAlias ? { prepareLoadout: () => ({ hiddenDeclarations: [toolName] }) } : {}), async execute(_toolCallId, params, signal) { + // Another extension may switch the public tool off after reconciliation. + if ( + isAlias && + !followsPublicTool(name, new Set(pi.getAllTools().map((tool) => tool.name)), pi.getActiveTools()) + ) { + return { content: [{ type: "text", text: \`Tool \${toolName} not found\` }], details: {}, isError: true }; + } const result = await client.callTool( name, (params ?? {}) as Record, @@ -458,7 +479,6 @@ export default async function t3McpExtension(pi: ExtensionAPI) { const exposureChanged = deferOptionalTools !== hasToolSearch; deferOptionalTools = hasToolSearch; if (exposureChanged) registerTools(); - const active = pi.getActiveTools(); // Completion gateways can silently drop calls to undeclared tools named // in history or guidance. Eagerly load T3 schemas, including after tree // restores, while retaining deferred exposure for Pi's allowlist rules. @@ -467,8 +487,18 @@ export default async function t3McpExtension(pi: ExtensionAPI) { ...(hasToolSearch ? ["tool_search"] : []), ...(ctx.model?.api === "openai-completions" ? catalog.map((tool) => "mcp__t3-code__" + tool.name) : []), ]; - const missing = required.filter((name) => !active.includes(name)); - if (missing.length > 0) pi.setActiveTools([...active, ...missing]); + const current = pi.getActiveTools(); + const missing = required.filter((name) => !current.includes(name)); + if (missing.length > 0) pi.setActiveTools([...current, ...missing]); + // An alias is callable only while active. + const registered = new Set(pi.getAllTools().map((tool) => tool.name)); + const loaded = pi.getActiveTools(); + const aliases = catalog + .filter((tool) => registered.has(ALIAS_PREFIX + tool.name) && followsPublicTool(tool.name, registered, loaded)) + .map((tool) => ALIAS_PREFIX + tool.name); + const kept = loaded.filter((name) => !name.startsWith(ALIAS_PREFIX) || aliases.includes(name)); + const added = aliases.filter((name) => !loaded.includes(name)); + if (added.length > 0 || kept.length !== loaded.length) pi.setActiveTools([...kept, ...added]); }; // Tree navigation restores its saved loadout after session_start.