1/**
2 * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.
3 *
4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion
5 * gateways need their schemas loaded up front. Older versions keep tools
6 * directly available. Pi loads this TypeScript via `--extension`; the
7 * server writes it to a cache so packaged builds need no sibling .ts file.
8 *
9 * Do not import t3code modules from the string body. The Pi process resolves
10 * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install.
11 */
12import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions";
13
14export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts";
15
16export const T3_MCP_URL_ENV = "T3_MCP_URL";
17export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN";
18export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE";
19export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";
20
21/**
22 * Pi tools whose confirmations the bridge raises as file-change approvals.
23 * Auto-accept edits skips them; the adapter keys the approval kind off them.
24 */
25export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const;
26
27export const PI_T3_MCP_EXTENSION_SOURCE = `\
28import { stripFrontmatter, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
29import * as NodeFSP from "node:fs/promises";
30import * as NodePath from "node:path";
31import { Type } from "typebox";
32
33const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)};
34const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)};
35const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)};
36const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)};
37const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())};
38const PROTOCOL = "2025-06-18";
39const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
40const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)});
41
42type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access";
43
44type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
45
46type JsonRpcResponse = {
47 readonly id?: number | string;
48 readonly result?: JsonValue;
49 readonly error?: { readonly message?: string };
50};
51
52type McpTool = {
53 readonly name: string;
54 readonly description?: string;
55 readonly inputSchema?: Record<string, unknown>;
56 readonly annotations?: { readonly readOnlyHint?: boolean };
57 readonly outputSchema?: Record<string, unknown>;
58};
59
60function env(name: string): string | undefined {
61 const value = process.env[name];
62 return value && value.length > 0 ? value : undefined;
63}
64
65function runtimeMode(): RuntimeMode {
66 const value = env(RUNTIME_MODE_ENV);
67 return value === "approval-required" ||
68 value === "auto-accept-edits" ||
69 value === "auto" ||
70 value === "full-access"
71 ? value
72 : "full-access";
73}
74
75function toolInputSummary(input: unknown): string {
76 try {
77 return JSON.stringify(input, null, 2).slice(0, 4_000);
78 } catch {
79 return String(input).slice(0, 4_000);
80 }
81}
82
83function parseSseOrJson(body: string, contentType: string): JsonRpcResponse {
84 if (contentType.includes("text/event-stream")) {
85 for (const line of body.split("\\n")) {
86 const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
87 if (trimmed.length === 0) continue;
88 const parsed = JSON.parse(trimmed) as JsonRpcResponse;
89 if (parsed.id !== undefined || parsed.result !== undefined || parsed.error !== undefined) {
90 return parsed;
91 }
92 }
93 throw new Error("MCP SSE response had no JSON-RPC payload.");
94 }
95 return JSON.parse(body) as JsonRpcResponse;
96}
97
98function jsonSchemaToTypebox(schema: Record<string, unknown> | undefined) {
99 const unsafe = (Type as { Unsafe?: (value: unknown) => unknown }).Unsafe;
100 if (typeof unsafe === "function" && schema !== undefined) {
101 return unsafe(schema);
102 }
103 return Type.Object({}, { additionalProperties: true });
104}
105
106type ModelContent =
107 | { type: "text"; text: string }
108 | { type: "image"; data: string; mimeType: string };
109
110function mcpModelContent(result: unknown): ModelContent[] {
111 if (result === null || result === undefined) return [];
112 if (typeof result !== "object") return [{ type: "text", text: String(result) }];
113 const record = result as {
114 readonly content?: ReadonlyArray<{
115 readonly type?: string;
116 readonly text?: string;
117 readonly data?: string;
118 readonly mimeType?: string;
119 }>;
120 readonly structuredContent?: unknown;
121 };
122 const content: ModelContent[] = [];
123 if (Array.isArray(record.content)) {
124 for (const part of record.content) {
125 if (part?.type === "text" && typeof part.text === "string") {
126 content.push({ type: "text", text: part.text });
127 } else if (part?.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
128 content.push({ type: "image", data: part.data, mimeType: part.mimeType });
129 }
130 }
131 }
132 // Structured output belongs to scripts. Only use it as model text when
133 // the server supplied no model-facing blocks, matching Pi's native MCP tools.
134 if (content.length === 0) {
135 content.push({ type: "text", text: JSON.stringify(record.structuredContent ?? result) });
136 }
137 return content;
138}
139
140function mcpScriptResult(result: JsonValue | undefined) {
141 if (typeof result !== "object" || result === null || Array.isArray(result)) return result;
142 const { _meta: _ignored, ...scriptResult } = result;
143 return scriptResult;
144}
145
146function mcpOutputSchema(tool: McpTool) {
147 return jsonSchemaToTypebox({
148 type: "object",
149 properties: {
150 content: { type: "array", items: { type: "object" } },
151 ...(tool.outputSchema === undefined ? {} : { structuredContent: tool.outputSchema }),
152 isError: { type: "boolean" },
153 _meta: { type: "object" },
154 },
155 required: ["content"],
156 });
157}
158
159function isMcpToolError(result: unknown): boolean {
160 return (
161 typeof result === "object" &&
162 result !== null &&
163 "isError" in result &&
164 result.isError === true
165 );
166}
167
168function createMcpClient(endpoint: string, token: string) {
169 let nextId = 1;
170 let sessionId: string | undefined;
171
172 const headers = (): Record<string, string> => {
173 const next: Record<string, string> = {
174 accept: "application/json, text/event-stream",
175 authorization: token.startsWith("Bearer ") ? token : \`Bearer \${token}\`,
176 "content-type": "application/json",
177 // Effect's HTTP MCP rejects post-initialize requests without this
178 // (400). The worktree client in McpHttpServer tests sends the same
179 // header; initialize itself does not require it.
180 "mcp-protocol-version": PROTOCOL,
181 };
182 if (sessionId !== undefined) next["mcp-session-id"] = sessionId;
183 return next;
184 };
185
186 const request = async (method: string, params?: unknown, signal?: AbortSignal) => {
187 const id = nextId++;
188 const response = await fetch(endpoint, {
189 method: "POST",
190 headers: headers(),
191 body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
192 signal,
193 });
194 const nextSession = response.headers.get("mcp-session-id");
195 if (nextSession) sessionId = nextSession;
196 const body = await response.text();
197 if (!response.ok) {
198 throw new Error(\`MCP \${method} failed (\${response.status}): \${body.slice(0, 400)}\`);
199 }
200 if (body.length === 0) return undefined;
201 const parsed = parseSseOrJson(body, response.headers.get("content-type") ?? "");
202 if (parsed.error) {
203 throw new Error(parsed.error.message ?? \`MCP \${method} returned an error\`);
204 }
205 return parsed.result;
206 };
207
208 const notify = async (method: string, params?: unknown, signal?: AbortSignal) => {
209 await fetch(endpoint, {
210 method: "POST",
211 headers: headers(),
212 body: JSON.stringify({ jsonrpc: "2.0", method, params }),
213 signal,
214 });
215 };
216
217 return {
218 async connect(signal?: AbortSignal) {
219 await request(
220 "initialize",
221 {
222 protocolVersion: PROTOCOL,
223 capabilities: {},
224 clientInfo: { name: "t3-pi-mcp", version: "1.0.0" },
225 },
226 signal,
227 );
228 await notify("notifications/initialized", {}, signal).catch(() => undefined);
229 },
230 async listTools(signal?: AbortSignal) {
231 const tools: McpTool[] = [];
232 let cursor: string | undefined;
233 do {
234 const result = (await request(
235 "tools/list",
236 cursor === undefined ? {} : { cursor },
237 signal,
238 )) as { tools?: McpTool[]; nextCursor?: string } | undefined;
239 tools.push(...(result?.tools ?? []));
240 cursor = result?.nextCursor;
241 } while (cursor);
242 return tools;
243 },
244 async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal) {
245 return request("tools/call", { name, arguments: args }, signal);
246 },
247 };
248}
249
250export default async function t3McpExtension(pi: ExtensionAPI) {
251 // Keep skill chips in the request. Pi expands only one leading /skill:
252 // command, so use its own frontmatter loader for every selected chip here.
253 pi.on("input", async (event, ctx) => {
254 if (!event.text.includes("$")) return;
255 const commands = pi.getCommands().filter((command) => command.source === "skill");
256 const nativeSkill = /^\\/skill:([^ ]+)(?: |$)/.exec(event.text)?.[1];
257 const seen = new Set<string>(nativeSkill === undefined ? [] : [nativeSkill]);
258 const blocks: string[] = [];
259 for (const match of event.text.matchAll(/(^|\\s)\\$([^\\s]+)(?=\\s|$)/g)) {
260 const name = match[2];
261 if (name === undefined || seen.has(name)) continue;
262 const command = commands.find((candidate) => candidate.name === "skill:" + name);
263 if (command === undefined) continue;
264 seen.add(name);
265 const path = command.sourceInfo.path;
266 try {
267 const body = stripFrontmatter(await NodeFSP.readFile(path, "utf8")).trim();
268 blocks.push(
269 \`<skill name="\${name}" location="\${path}">\\nReferences are relative to \${NodePath.dirname(path)}.\\n\\n\${body}\\n</skill>\`,
270 );
271 } catch {
272 ctx.ui.notify(\`Could not load skill \${name} from \${path}.\`, "error");
273 }
274 }
275 if (blocks.length === 0) return;
276 return {
277 action: "transform",
278 text: event.text + "\\n\\n" + blocks.join("\\n\\n"),
279 images: event.images,
280 };
281 });
282
283 // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the
284 // previous response's usage even when a fork's instructions/tools differ,
285 // then reserves almost all remaining context for output. OpenRouter can
286 // reject even a short conversation. Remove this cap when Pi accounts for
287 // the current request prefix reliably (api/simple-options + utils/estimate).
288 pi.on("before_provider_request", (event, ctx) => {
289 if (ctx.model?.provider !== "openrouter") return;
290 const payload = event.payload;
291 if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return;
292 const replacement = { ...payload } as Record<string, unknown>;
293 let changed = false;
294 for (const key of ["max_tokens", "max_completion_tokens"]) {
295 const limit = replacement[key];
296 if (typeof limit === "number" && Number.isFinite(limit) && limit > 32_768) {
297 replacement[key] = 32_768;
298 changed = true;
299 }
300 }
301 if (changed) return replacement;
302 });
303
304 // Pi deliberately leaves permission policy to extensions. T3's injected
305 // bridge uses Pi's public blocking tool hook so the shared runtime modes
306 // keep their normal meaning without replacing or shadowing Pi's runtime.
307 // Only Pi's own search is known to be read-only. An extension that replaces
308 // it keeps the name, and cannot discover deferred tools either.
309 const hasBuiltinToolSearch = () =>
310 typeof pi.getAllTools === "function" &&
311 pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search");
312
313 const readOnlyMcpTools = new Set<string>();
314 const isReadOnlyMcpTool = (name: string) => {
315 const extensionPath = env(EXTENSION_PATH_ENV);
316 if (extensionPath === undefined || !readOnlyMcpTools.has(name) || typeof pi.getAllTools !== "function") return false;
317 // A user extension may own the same name. Only our registered HTTP bridge
318 // may inherit the canonical T3 server's read-only annotation.
319 return pi.getAllTools().some((tool) => tool.name === name &&
320 typeof tool.sourceInfo?.path === "string" &&
321 NodePath.resolve(tool.sourceInfo.path) === NodePath.resolve(extensionPath));
322 };
323
324 pi.on("tool_call", async (event, ctx) => {
325 const mode = runtimeMode();
326 if (mode === "full-access") return;
327 if ((event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) || isReadOnlyMcpTool(event.toolName)) {
328 return;
329 }
330 if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) {
331 return;
332 }
333 const approved = await ctx.ui.confirm(
334 \`Allow \${event.toolName}?\`,
335 toolInputSummary(event.input),
336 { signal: ctx.signal },
337 );
338 if (!approved) {
339 return { block: true, reason: \`\${event.toolName} was declined in T3 Code.\` };
340 }
341 });
342
343 const endpoint = env(URL_ENV);
344 const token = env(TOKEN_ENV);
345 if (endpoint === undefined || token === undefined) {
346 pi.on("session_start", async (_event, ctx) => {
347 ctx.ui.notify(
348 "t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.",
349 "warning",
350 );
351 });
352 return;
353 }
354
355 // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP
356 // bridge as the credential owner: mcp.json overrides native registrations.
357 const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function";
358 const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]);
359 let deferOptionalTools = supportsExposure;
360 let catalog: ReadonlyArray<McpTool> = [];
361
362 const client = createMcpClient(endpoint, token);
363 let started: Promise<void> | undefined;
364
365 const registerTools = () => {
366 // Preserve public names for saved loadouts and tool selectors. Hidden
367 // canonical names reserve ownership against Pi's configured MCP servers.
368 const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"];
369 readOnlyMcpTools.clear();
370 for (const tool of catalog) {
371 const name = tool.name;
372 for (const prefix of prefixes) {
373 if (tool.annotations?.readOnlyHint === true) readOnlyMcpTools.add(\`\${prefix}\${name}\`);
374 const exposure = prefix === "mcp__t3_code__" ? "hidden" :
375 deferOptionalTools && !directTools.has(name) ? "deferred" : "direct";
376 pi.registerTool({
377 name: \`\${prefix}\${name}\`,
378 label: name,
379 description: tool.description ?? name,
380 parameters: jsonSchemaToTypebox(tool.inputSchema),
381 ...(supportsExposure ? { outputSchema: mcpOutputSchema(tool) } : {}),
382 ...(supportsExposure ? { exposure } : {}),
383 async execute(_toolCallId, params, signal) {
384 const result = await client.callTool(
385 name,
386 (params ?? {}) as Record<string, unknown>,
387 signal,
388 );
389 const content = mcpModelContent(result);
390 if (isMcpToolError(result) && !content.some((part) => part.type === "text" && part.text.length > 0)) {
391 content.push({ type: "text", text: "MCP tool t3-code/" + name + " returned an error" });
392 }
393 return {
394 content,
395 structuredContent: mcpScriptResult(result),
396 details: { server: "t3-code", tool: name },
397 ...(isMcpToolError(result) ? { isError: true } : {}),
398 };
399 },
400 });
401 }
402 }
403 };
404
405 const ensureStarted = () => {
406 if (started !== undefined) return started;
407 const attempt = (async () => {
408 const signal = AbortSignal.timeout(10_000);
409 await client.connect(signal);
410 catalog = await client.listTools(signal);
411 registerTools();
412 })();
413 started = attempt;
414 void attempt.catch(() => {
415 if (started === attempt) started = undefined;
416 });
417 return attempt;
418 };
419
420 // CLI extensions load before builtins, and Pi keeps the first registration
421 // of a tool name. Register now so the bridge owns the T3 namespace even when
422 // mcp.json configures it; retry a failed connection at session_start.
423 await ensureStarted().catch(() => undefined);
424
425 const reconcileDiscovery = (ctx: Pick<ExtensionContext, "model">) => {
426 if (!supportsExposure) return;
427 // A disabled or replaced search builtin cannot discover deferred tools.
428 const hasToolSearch = hasBuiltinToolSearch();
429 const exposureChanged = deferOptionalTools !== hasToolSearch;
430 deferOptionalTools = hasToolSearch;
431 if (exposureChanged) registerTools();
432 const active = pi.getActiveTools();
433 // Completion gateways can silently drop calls to undeclared tools named
434 // in history or guidance. Eagerly load T3 schemas, including after tree
435 // restores, while retaining deferred exposure for Pi's allowlist rules.
436 // Pi still applies --tools/--exclude-tools to this active loadout.
437 const required = [
438 ...(hasToolSearch ? ["tool_search"] : []),
439 ...(ctx.model?.api === "openai-completions" ? catalog.map((tool) => "mcp__t3-code__" + tool.name) : []),
440 ];
441 const missing = required.filter((name) => !active.includes(name));
442 if (missing.length > 0) pi.setActiveTools([...active, ...missing]);
443 };
444
445 // Tree navigation restores its saved loadout after session_start.
446 pi.on("session_tree", (_event, ctx) => reconcileDiscovery(ctx));
447 pi.on("model_select", (_event, ctx) => reconcileDiscovery(ctx));
448 pi.on("session_start", async (_event, ctx) => {
449 reconcileDiscovery(ctx);
450 try {
451 await ensureStarted();
452 } catch (error) {
453 const message = error instanceof Error ? error.message : String(error);
454 ctx.ui.notify(\`t3-code MCP unavailable: \${message}\`, "warning");
455 }
456 });
457
458 // Deliver orchestration guidance through pi's real system-prompt channel.
459 // Wrapping the first user message instead would stop it from starting
460 // with "/" and silently break slash-command expansion.
461 pi.on("before_agent_start", (event, ctx) => {
462 reconcileDiscovery(ctx);
463 return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS };
464 });
465}
466`;