import { env, waitUntil } from "cloudflare:workers"; import { z } from "zod"; import { HttpError } from "../../errors"; import { assertVerifiedForWrites } from "../../verified-writes"; import { randomToken, sha256Hex } from "../../crypto"; import { MergeError } from "../../pulls"; import { auditToolCall, limitToolCall } from "../audit"; import { recordToolUsage } from "../usage"; import { RPC, RpcError } from "../protocol"; import type { JsonObject, McpCaller } from "../protocol"; import { accessTools } from "./access"; import type { Tool, ToolContext, ToolResult } from "./base"; import { ToolError } from "./base"; import { ciTools } from "./ci"; import { coordinationTools } from "./coordination"; import { editorTools } from "./editor"; import { gitTools } from "./git"; import { issueTools } from "./issues"; import { pullTools } from "./pulls"; import { repoTools } from "./repos"; import { siteTools } from "./sites"; import { stackTools } from "./stacks"; const MAX_STRUCTURED_BYTES = 256 * 1024; const REQUEST_STATE_TTL_SECONDS = 600; const STATE_PREFIX = "mcp-mrtr:"; const DENIED_STATUSES = new Set([401, 403, 404]); /** Limit errors thrown inside the coordination hub arrive as plain Errors over Durable Object RPC. */ const COORDINATION_LIMIT = /^Too many (active claims|claims held|subscribers)/; const TOOLS: Tool[] = [ ...repoTools, ...gitTools, ...pullTools, ...stackTools, ...issueTools, ...ciTools, ...accessTools, ...siteTools, ...coordinationTools, ...editorTools, ]; const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool])); function jsonSchema(schema: z.ZodType): JsonObject { const generated = z.toJSONSchema(schema, { target: "draft-2020-12", io: "input" }); const { $schema: _ignored, ...rest } = generated; return rest; } /** Sorted by name so the list is deterministic and cacheable (2026-07-28 guidance). */ export function listTools(): JsonObject[] { return [...TOOLS] .sort((left, right) => left.name.localeCompare(right.name)) .map((tool) => ({ name: tool.name, title: tool.title, description: tool.description, inputSchema: jsonSchema(tool.input), ...(tool.output ? { outputSchema: z.toJSONSchema(tool.output, { target: "draft-2020-12", io: "output" }) } : {}), annotations: { title: tool.title, readOnlyHint: !tool.write, destructiveHint: tool.destructive ?? false, idempotentHint: !tool.write, openWorldHint: false, }, })); } function errorResult(message: string): JsonObject { return { content: [{ type: "text", text: message }], isError: true }; } function successResult(result: ToolResult): JsonObject { let serialized = JSON.stringify(result.structured); let structured: unknown = result.structured; if (serialized.length > MAX_STRUCTURED_BYTES) { structured = { truncated: true, note: "Result exceeded the output budget; narrow the request" }; serialized = JSON.stringify(structured); } return { content: [{ type: "text", text: serialized }, ...(result.links ?? [])], structuredContent: structured, isError: false, }; } type StoredState = { tokenId: string | null; tool: string; argsHash: string }; async function argsHash(args: unknown): Promise { return sha256Hex(JSON.stringify(args ?? {})); } /** Retries of an input_required result must come from the same token, for the same tool and arguments. */ async function verifyRequestState(caller: McpCaller, name: string, args: unknown, raw: unknown): Promise { if (typeof raw !== "string") throw new RpcError(RPC.invalidParams, "requestState must be a string"); const key = `${STATE_PREFIX}${await sha256Hex(raw)}`; const state = await env.KV.get(key, "json"); await env.KV.delete(key); if (!state || state.tokenId !== caller.tokenId || state.tool !== name || state.argsHash !== (await argsHash(args))) { throw new RpcError(RPC.invalidParams, "requestState is invalid or expired"); } } function classify(error: unknown): { message: string; outcome: "denied" | "error" } | null { if (error instanceof ToolError) return { message: error.message, outcome: error.denied ? "denied" : "error" }; if (error instanceof HttpError) { const denied = DENIED_STATUSES.has(error.status); return { message: error.message, outcome: denied ? "denied" : "error" }; } if (error instanceof MergeError) return { message: error.message, outcome: "error" }; if (error instanceof Error && COORDINATION_LIMIT.test(error.message)) return { message: error.message, outcome: "error" }; if (error instanceof z.ZodError) { return { message: `Invalid arguments: ${error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, outcome: "error", }; } return null; } export async function callTool( caller: McpCaller, params: JsonObject, hooks: Pick, ): Promise { const name = typeof params.name === "string" ? params.name : ""; const tool = BY_NAME.get(name); if (!tool) throw new RpcError(RPC.invalidParams, `Unknown tool: ${name}`); const args = params.arguments ?? {}; const inputResponses = typeof params.inputResponses === "object" && params.inputResponses !== null ? (params.inputResponses as JsonObject) : null; let repoId: string | null = null; try { // The limit comes first so a throttled caller can't still drive request-state lookups or tool work. await limitToolCall(caller, tool.write); if (tool.write && !tool.unverifiedOk) assertVerifiedForWrites(caller.viewer); if (inputResponses) await verifyRequestState(caller, name, args, params.requestState); const outcome = await tool.run({ caller, inputResponses, ...hooks }, args); if ("inputRequired" in outcome) { const requestState = randomToken("rs"); const state: StoredState = { tokenId: caller.tokenId, tool: name, argsHash: await argsHash(args) }; await env.KV.put(`${STATE_PREFIX}${await sha256Hex(requestState)}`, JSON.stringify(state), { expirationTtl: REQUEST_STATE_TTL_SECONDS, }); return { resultType: "input_required", inputRequests: outcome.inputRequired, requestState }; } repoId = outcome.repoId ?? null; if (tool.write) await auditToolCall({ caller, tool: name, args, repoId, outcome: "ok", detail: null }); waitUntil(recordToolUsage(caller.viewer.id, name, false)); return successResult(outcome); } catch (error) { if (error instanceof RpcError) throw error; const known = classify(error); if (!known) console.error("MCP tool failed", name, error); const message = known?.message ?? "Internal error"; if (tool.write) await auditToolCall({ caller, tool: name, args, repoId, outcome: known?.outcome ?? "error", detail: message }); waitUntil(recordToolUsage(caller.viewer.id, name, true)); return errorResult(message); } }