| 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 | */ |
| 12 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; |
| 13 | |
| 14 | export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; |
| 15 | |
| 16 | export const T3_MCP_URL_ENV = "T3_MCP_URL"; |
| 17 | export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; |
| 18 | export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; |
| 19 | export 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 | */ |
| 25 | export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; |
| 26 | |
| 27 | export const PI_T3_MCP_EXTENSION_SOURCE = `\ |
| 28 | import { stripFrontmatter, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; |
| 29 | import * as NodeFSP from "node:fs/promises"; |
| 30 | import * as NodePath from "node:path"; |
| 31 | import { Type } from "typebox"; |
| 32 | |
| 33 | const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; |
| 34 | const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; |
| 35 | const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; |
| 36 | const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)}; |
| 37 | const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; |
| 38 | const PROTOCOL = "2025-06-18"; |
| 39 | const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); |
| 40 | const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); |
| 41 | |
| 42 | type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; |
| 43 | |
| 44 | type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; |
| 45 | |
| 46 | type JsonRpcResponse = { |
| 47 | readonly id?: number | string; |
| 48 | readonly result?: JsonValue; |
| 49 | readonly error?: { readonly message?: string }; |
| 50 | }; |
| 51 | |
| 52 | type 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 | |
| 60 | function env(name: string): string | undefined { |
| 61 | const value = process.env[name]; |
| 62 | return value && value.length > 0 ? value : undefined; |
| 63 | } |
| 64 | |
| 65 | function 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 | |
| 75 | function 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 | |
| 83 | function 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 | |
| 98 | function 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 | |
| 106 | type ModelContent = |
| 107 | | { type: "text"; text: string } |
| 108 | | { type: "image"; data: string; mimeType: string }; |
| 109 | |
| 110 | function 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 | |
| 140 | function 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 | |
| 146 | function 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 | |
| 159 | function isMcpToolError(result: unknown): boolean { |
| 160 | return ( |
| 161 | typeof result === "object" && |
| 162 | result !== null && |
| 163 | "isError" in result && |
| 164 | result.isError === true |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | function 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 | |
| 250 | export 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 | `; |