Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/provider-pi/src/server/mcpBridge.testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ interface RegisteredTool {

type AgentStartHook = (
event: { systemPrompt: string },
ctx: { ui: { notify: (message: string, severity: string) => void } },
ctx: {
model?: { api: string };
ui: { notify: (message: string, severity: string) => void };
},
) => Promise<{ systemPrompt: string }>;

export async function loadMcpBridge(
Expand Down
79 changes: 61 additions & 18 deletions packages/provider-pi/src/server/mcpExtensionSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,40 @@ describe("Pi MCP tool results", () => {
});

describe("Pi MCP tool exposure", () => {
const discoveryContext = {
model: { api: "anthropic-messages" },
ui: { notify: () => undefined },
};

it.each(["session_start", "session_tree", "model_select", "before_agent_start"])(
"declares optional T3 tools for OpenAI-compatible gateways at %s",
async (hookName) => {
const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
const hook = bridge.handlers.get(hookName);
assert.isDefined(hook);
await hook!(
{ systemPrompt: "Pi system prompt" },
{ model: { api: "openai-completions" }, ui: { notify: assert.fail } },
);
assert.isTrue(
bridge.tools
.filter((tool) => tool.name.startsWith("mcp__t3-code__"))
.every((tool) => bridge.getActiveTools().includes(tool.name)),
);
const result = await bridge.tools
.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!
.execute("gateway-call", {});
assert.equal(result.content[0]?.text, "browser snapshot");
assert.equal(bridge.tools.filter((tool) => tool.exposure === "hidden").length, 4);
},
);

it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {
const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
await bridge.handlers.get("session_start")!(
{ systemPrompt: "" },
{ ui: { notify: () => undefined } },
);
await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
const start = bridge.handlers.get("before_agent_start");
assert.isDefined(start);
const prompt = await start!(
{ systemPrompt: "Pi system prompt" },
{ ui: { notify: () => undefined } },
);
const prompt = await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
assert.include(prompt.systemPrompt, "orchestrator_capabilities");
assert.equal(bridge.servers.length, 0);
assert.equal(bridge.tools.length, 8);
Expand Down Expand Up @@ -154,6 +176,33 @@ describe("Pi MCP tool exposure", () => {
assert.equal(bridge.requests.at(-1)?.method, "tools/call");
});

it("restores gateway declarations after model switches and older tree loadouts without excluded tools", async () => {
const bridge = await loadMcpBridge({
modern: true,
toolSearchAvailable: true,
allowsTool: (name) => name !== "mcp__t3-code__delegate_task",
});
const event = { systemPrompt: "" };
await bridge.handlers.get("session_start")!(event, discoveryContext);
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"]);
await bridge.handlers.get("session_tree")!(event, gatewayContext);
assert.deepEqual(bridge.getActiveTools(), [
"read",
"mcp__t3-code__task_status",
"tool_search",
"mcp__t3-code__orchestrator_capabilities",
"mcp__t3-code__preview_snapshot",
]);
await bridge.handlers.get("model_select")!(event, discoveryContext);
assert.equal(
bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")?.exposure,
"deferred",
);
assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot");
});

it.each(["legacy Pi", "disabled tool search"])(
"keeps tool execution available with %s",
async (mode) => {
Expand All @@ -164,7 +213,7 @@ describe("Pi MCP tool exposure", () => {
});
if (mode !== "legacy Pi") {
const start = bridge.handlers.get("session_start");
await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } });
await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
}
assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);
assert.isTrue(
Expand Down Expand Up @@ -198,10 +247,7 @@ describe("Pi MCP tool exposure", () => {
allowsTool: (name) =>
name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),
});
await bridge.handlers.get("session_start")!(
{ systemPrompt: "" },
{ ui: { notify: () => undefined } },
);
await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
assert.equal(bridge.tools.length, 4);
assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);
const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
Expand All @@ -218,18 +264,15 @@ describe("Pi MCP tool exposure", () => {
allowsTool: (name) =>
name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),
});
await bridge.handlers.get("session_start")!(
{ systemPrompt: "" },
{ ui: { notify: () => undefined } },
);
await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
bridge.restoreActiveTools([
"read",
"mcp__t3-code__task_status",
"mcp__t3-code__preview_snapshot",
]);
const tree = bridge.handlers.get("session_tree");
assert.isDefined(tree);
await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } });
await tree!({ systemPrompt: "" }, discoveryContext);
assert.deepEqual(bridge.getActiveTools(), [
"read",
"mcp__t3-code__task_status",
Expand Down
36 changes: 23 additions & 13 deletions packages/provider-pi/src/server/mcpExtensionSource.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.
*
* Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions
* keep them directly available. Pi loads this TypeScript via `--extension`; the
* Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion
* gateways need their schemas loaded up front. Older versions keep tools
* directly available. Pi loads this TypeScript via `--extension`; the
* server writes it to a cache so packaged builds need no sibling .ts file.
*
* Do not import t3code modules from the string body. The Pi process resolves
Expand All @@ -24,7 +25,7 @@ export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";
export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const;

export const PI_T3_MCP_EXTENSION_SOURCE = `\
import { stripFrontmatter, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { stripFrontmatter, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import { Type } from "typebox";
Expand Down Expand Up @@ -421,23 +422,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
// mcp.json configures it; retry a failed connection at session_start.
await ensureStarted().catch(() => undefined);

const reconcileDiscovery = () => {
const reconcileDiscovery = (ctx: Pick<ExtensionContext, "model">) => {
if (!supportsExposure) return;
// A disabled or replaced search builtin cannot discover deferred tools.
const hasToolSearch = hasBuiltinToolSearch();
const exposureChanged = deferOptionalTools !== hasToolSearch;
deferOptionalTools = hasToolSearch;
if (exposureChanged) registerTools();
if (hasToolSearch) {
const active = pi.getActiveTools();
if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]);
}
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.
// Pi still applies --tools/--exclude-tools to this active loadout.
const required = [
...(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]);
};

// Tree navigation restores its saved loadout after session_start.
pi.on("session_tree", reconcileDiscovery);
pi.on("session_tree", (_event, ctx) => reconcileDiscovery(ctx));
pi.on("model_select", (_event, ctx) => reconcileDiscovery(ctx));
pi.on("session_start", async (_event, ctx) => {
reconcileDiscovery();
reconcileDiscovery(ctx);
try {
await ensureStarted();
} catch (error) {
Expand All @@ -449,8 +458,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
// Deliver orchestration guidance through pi's real system-prompt channel.
// Wrapping the first user message instead would stop it from starting
// with "/" and silently break slash-command expansion.
pi.on("before_agent_start", (event) => ({
systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS,
}));
pi.on("before_agent_start", (event, ctx) => {
reconcileDiscovery(ctx);
return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS };
});
}
`;
Loading