From 9036d3c4848e4e050865895b50fd8de732a2eca2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:02:58 -0500 Subject: [PATCH] feat(runner): add authenticated semantic MCP bridge (#12402) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native providers can receive only the semantic operations authorized for one run. > - Codex ACP consumes those operations through an MCP endpoint. > - The endpoint must be private, authenticated, bounded, and deterministic under retries. > - It must not advertise runner-private operations or allow callers to replace terminal-result schemas. > - This pull request adds a provider-neutral loopback MCP bridge with those controls. ## Linked Issues or Issue Description **What would you like to improve?** The runner has a run-scoped semantic catalog and dispatcher, but the ACPX runtime needs a secure transport for that catalog. A generic local MCP server could expose extra operations, accept ambiguous tool definitions, or execute the same call twice after a retry. **Why is this important?** Semantic tool presence is part of the authorization boundary. Undiscoverable operations must remain unavailable. Terminal completion and blocked-result schemas must not be replaceable. Duplicate call identities must be idempotent, and conflicting duplicates must fail closed. **Suggested approach** Bind one MCP endpoint to `127.0.0.1` for each admitted runtime. Require a random bearer secret. Compile the closed tool schemas before listening. Keep private operations out of `tools/list`. Validate and fingerprint each call before dispatch. Bound request size, result size, time, and retained call identities. Abort active operations on timeout, cancellation, or bridge close. **Additional context** #12401 is merged. This PR does not attach the bridge to ACPX, register a provider, or change any server or direct-adapter behavior. ## What Changed - Add a provider-neutral runner semantic MCP bridge bound only to IPv4 loopback. - Require constant-time bearer authentication before MCP operations. - Expose only the supplied public catalog plus fixed completion and blocked-result tools. - Keep runner-private operations callable by trusted extensions but absent from discovery. - Reject invalid names, duplicate definitions, public/private collisions, and terminal schema replacement. - Compile JSON Schema validators before accepting traffic. - Validate calls before dispatch and replay identical duplicate identities exactly once. - Treat numeric and string JSON-RPC identities as distinct and reject conflicting duplicates. - Bound request bodies, result text, timeouts, retained identities, and concurrent capacity. - Terminate oversized request bodies, preserve successful mutation outcomes, and keep complete semantic results. - Propagate MCP cancellation and abort active calls during close. ## Verification - Exact verified head: `e5070e235448680e480e8d3f66bb46ac62d71c8e`. - Full GitHub PR workflow passed in [run 33342776925](https://github.com/paperclipai/paperclip/actions/runs/33342776925), including runner verification/build, typecheck, all test shards, canary, and e2e. - Greptile is 5/5 on the exact head with zero unresolved review threads. - Superagent Security, Snyk, contributor trust, and commitperclip passed on the exact head. - Storybook skipped by path as expected. - The diff contains 2 files and does not change dependencies, `pnpm-lock.yaml`, workflows, migrations, server selection, or UI behavior. - No additional local suite was run during the final restack; GitHub Actions is the authoritative verification environment. ## Risks The main risk is widening model-visible authority. The bridge exposes only its closed public catalog and fixed terminal tools; private operations are omitted from discovery and catalog ambiguity fails during startup. Another risk is duplicate execution after a provider retry. The bridge fingerprints each admitted JSON-RPC identity, reuses the first promise for exact retries, and rejects changed payloads. The endpoint uses loopback plus a per-runtime bearer secret and has no production caller in this pull request. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with version and capability details - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing public item or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have added or updated tests where applicable - [x] I have documented the authorization, network, idempotency, and rollout risks - [x] All applicable GitHub Actions are green - [x] Greptile is 5/5 with every actionable comment resolved - [x] I have addressed all review findings before merge --- .../src/drivers/runner-tool-bridge.test.ts | 447 +++++++++ .../src/drivers/runner-tool-bridge.ts | 851 ++++++++++++++++++ 2 files changed, 1298 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts create mode 100644 packages/paperclip-runner/src/drivers/runner-tool-bridge.ts diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts new file mode 100644 index 0000000000..b552adcb28 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts @@ -0,0 +1,447 @@ +import { connect } from "node:net"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + canonicalRunnerToolName, + startRunnerToolBridge, + type RunnerToolBridge, +} from "./runner-tool-bridge.js"; + +const bridges: RunnerToolBridge[] = []; + +afterEach(async () => { + await Promise.all(bridges.splice(0).map((bridge) => bridge.close())); +}); + +async function rpc( + bridge: RunnerToolBridge, + body: Record, + secret = bridge.secret, +): Promise { + return fetch(bridge.url, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", ...body }), + }); +} + +describe("runner semantic MCP bridge", () => { + it("binds to loopback, requires authentication, and exposes a closed catalog", async () => { + const bridge = await startRunnerToolBridge({ + secret: "session-secret", + tools: [tool("documents.read")], + handler: async () => ({ ok: true }), + }); + bridges.push(bridge); + + expect(new URL(bridge.url).hostname).toBe("127.0.0.1"); + expect( + (await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status, + ).toBe(401); + expect( + await (await rpc(bridge, { id: 2, method: "tools/list" })).json(), + ).toMatchObject({ + result: { + tools: [ + { name: "documents.read" }, + { name: "paperclip_finish" }, + { name: "paperclip_block" }, + ], + }, + }); + }); + + it("keeps private operations callable but undiscoverable", async () => { + const handler = vi.fn(async ({ tool: name }) => ({ name })); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + privateTools: [tool("__paperclip_permission")], + handler, + }); + bridges.push(bridge); + const listed = (await ( + await rpc(bridge, { id: 1, method: "tools/list" }) + ).json()) as { result: { tools: Array<{ name: string }> } }; + expect(listed.result.tools.map(({ name }) => name)).not.toContain( + "__paperclip_permission", + ); + expect( + await ( + await rpc(bridge, { + id: "private-1", + method: "tools/call", + params: { name: "__paperclip_permission", arguments: {} }, + }) + ).json(), + ).toMatchObject({ + result: { + content: [{ text: expect.stringContaining("__paperclip_permission") }], + }, + }); + }); + + it("normalizes names and executes identical duplicate calls once", async () => { + const handler = vi.fn(async () => ({ value: 7 })); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + handler, + }); + bridges.push(bridge); + const request = { + id: "call-1", + method: "tools/call", + params: { + name: "paperclip_documents.read", + arguments: { id: "doc" }, + }, + }; + + expect(await (await rpc(bridge, request)).json()).toMatchObject({ + result: { content: [{ text: '{"value":7}' }] }, + }); + expect(await (await rpc(bridge, request)).json()).toMatchObject({ + result: { content: [{ text: '{"value":7}' }] }, + }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + tool: "documents.read", + callId: "call-1", + arguments: { id: "doc" }, + }), + ); + }); + + it("rejects conflicting duplicate identities and validates before dispatch", async () => { + const handler = vi.fn(async () => ({ ok: true })); + const bridge = await startRunnerToolBridge({ + tools: [ + { + name: "documents.read", + inputSchema: { + type: "object", + required: ["id"], + properties: { id: { type: "string" } }, + additionalProperties: false, + }, + }, + ], + handler, + }); + bridges.push(bridge); + + expect( + await ( + await rpc(bridge, { + id: "same", + method: "tools/call", + params: { name: "documents.read", arguments: { id: 9 } }, + }) + ).json(), + ).toMatchObject({ result: { isError: true } }); + expect(handler).not.toHaveBeenCalled(); + await rpc(bridge, { + id: "same", + method: "tools/call", + params: { name: "documents.read", arguments: { id: "a" } }, + }); + expect( + await ( + await rpc(bridge, { + id: "same", + method: "tools/call", + params: { name: "documents.read", arguments: { id: "b" } }, + }) + ).json(), + ).toMatchObject({ + result: { + isError: true, + content: [{ text: "Duplicate call identity conflict." }], + }, + }); + }); + + it("keeps numeric and string JSON-RPC identities distinct", async () => { + const handler = vi.fn(async ({ callId }) => ({ callId })); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + handler, + }); + bridges.push(bridge); + + await rpc(bridge, { + id: 1, + method: "tools/call", + params: { name: "documents.read", arguments: { kind: "number" } }, + }); + await rpc(bridge, { + id: "1", + method: "tools/call", + params: { name: "documents.read", arguments: { kind: "string" } }, + }); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("times out calls and honors MCP cancellation", async () => { + const starts: string[] = []; + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + timeoutMs: 20, + handler: ({ callId, signal }) => + new Promise((_resolve, reject) => { + starts.push(callId); + signal.addEventListener( + "abort", + () => reject(new Error("handler aborted")), + { once: true }, + ); + }), + }); + bridges.push(bridge); + + expect( + await ( + await rpc(bridge, { + id: "slow", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }) + ).json(), + ).toMatchObject({ + result: { + isError: true, + content: [{ text: "Paperclip tool call timed out" }], + }, + }); + const pending = rpc(bridge, { + id: "cancel-me", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }); + await vi.waitFor(() => expect(starts).toContain("cancel-me"), { + interval: 1, + timeout: 15, + }); + expect( + ( + await rpc(bridge, { + method: "notifications/cancelled", + params: { requestId: "cancel-me" }, + }) + ).status, + ).toBe(202); + expect(await (await pending).json()).toMatchObject({ + result: { + isError: true, + content: [{ text: "Paperclip tool call cancelled" }], + }, + }); + }); + + it("preserves successful mutation identity when the result is oversized", async () => { + const result = { text: `snowman-${"☃".repeat(65 * 1024)}` }; + const handler = vi.fn(async () => result); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + handler, + }); + bridges.push(bridge); + const request = { + id: "large-result", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }; + + const bodies = []; + for (const response of [ + await rpc(bridge, request), + await rpc(bridge, request), + ]) { + const body = (await response.json()) as { + result: { content: Array<{ text: string }>; isError?: boolean }; + }; + expect(body.result).not.toHaveProperty("isError"); + const manifest = JSON.parse(body.result.content[0]!.text) as { + schema: string; + encoding: string; + chunkCount: number; + byteLength: number; + sha256: string; + }; + const serialized = body.result.content + .slice(1) + .map(({ text }) => text) + .join(""); + expect(manifest).toMatchObject({ + schema: "paperclip.semantic_tool_result_chunks.v1", + encoding: "json", + chunkCount: body.result.content.length - 1, + byteLength: Buffer.byteLength(serialized), + }); + expect( + body.result.content + .slice(1) + .every(({ text }) => Buffer.byteLength(text) <= 64 * 1024), + ).toBe(true); + expect(JSON.parse(serialized)).toEqual(result); + bodies.push(body); + } + expect(bodies[1]).toEqual(bodies[0]); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("returns a complete tagged receipt for cyclic results without re-executing", async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + cyclic.revision = 9_007_199_254_740_993n; + const handler = vi.fn(async () => cyclic); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + handler, + }); + bridges.push(bridge); + + const request = { + id: "cyclic-result", + method: "tools/call", + params: { name: "documents.read", arguments: {} }, + }; + const first = (await (await rpc(bridge, request)).json()) as { + result: { content: Array<{ text: string }>; isError?: boolean }; + }; + const second = (await (await rpc(bridge, request)).json()) as typeof first; + expect(first.result).not.toHaveProperty("isError"); + expect(JSON.parse(first.result.content[0]!.text)).toMatchObject({ + schema: "paperclip.semantic_tool_result.v1", + status: "completed", + tool: "documents.read", + callIdentitySha256: expect.stringMatching(/^[0-9a-f]{64}$/), + encoding: "paperclip.tagged_graph.v1", + result: { + root: { $ref: 1 }, + nodes: [ + { + id: 1, + type: "Object", + properties: [ + { key: "self", value: { $ref: 1 } }, + { + key: "revision", + value: { $type: "bigint", value: "9007199254740993" }, + }, + ], + }, + ], + }, + }); + expect(second).toEqual(first); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("destroys oversized and stalled request bodies before dispatch", async () => { + const handler = vi.fn(async () => ({ ok: true })); + const bridge = await startRunnerToolBridge({ + tools: [tool("documents.read")], + handler, + maxBodyBytes: 64, + requestBodyTimeoutMs: 20, + }); + bridges.push(bridge); + + await expect( + rpc(bridge, { + id: "oversized", + method: "tools/call", + params: { + name: "documents.read", + arguments: { value: "x".repeat(128) }, + }, + }), + ).rejects.toThrow(); + + const endpoint = new URL(bridge.url); + const socket = connect({ + host: endpoint.hostname, + port: Number(endpoint.port), + }); + const closed = new Promise((resolve) => + socket.once("close", () => resolve()), + ); + socket.write( + [ + "POST /mcp HTTP/1.1", + `Host: ${endpoint.host}`, + `Authorization: Bearer ${bridge.secret}`, + "Content-Type: application/json", + "Transfer-Encoding: chunked", + "", + "5", + "{", + ].join("\r\n"), + ); + await expect(closed).resolves.toBeUndefined(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("prevents catalog ambiguity and reserved schema replacement", async () => { + await expect( + startRunnerToolBridge({ + tools: [tool("documents.read"), tool("paperclip_documents.read")], + handler: async () => null, + }), + ).rejects.toThrow("duplicated"); + await expect( + startRunnerToolBridge({ + tools: [tool("paperclip_finish")], + handler: async () => null, + }), + ).rejects.toThrow("reserved by the protocol"); + await expect( + startRunnerToolBridge({ + tools: [tool("documents.read")], + privateTools: [tool("documents.read")], + handler: async () => null, + }), + ).rejects.toThrow("both public and private"); + }); + + it("rejects unsupported HTTP shapes and closes idempotently", async () => { + const bridge = await startRunnerToolBridge({ + tools: [], + handler: async () => null, + }); + bridges.push(bridge); + expect( + ( + await fetch(bridge.url, { + method: "POST", + headers: { Authorization: `Bearer ${bridge.secret}` }, + body: "{}", + }) + ).status, + ).toBe(415); + await bridge.close(); + await bridge.close(); + bridges.splice(bridges.indexOf(bridge), 1); + }); + + it("normalizes every supported provider prefix", () => { + expect(canonicalRunnerToolName("paperclip_documents.read")).toBe( + "documents.read", + ); + expect(canonicalRunnerToolName("paperclip__documents.read")).toBe( + "documents.read", + ); + expect(canonicalRunnerToolName("paperclip.documents.read")).toBe( + "documents.read", + ); + }); +}); + +function tool(name: string): Readonly> { + return { name, inputSchema: { type: "object" } }; +} diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts new file mode 100644 index 0000000000..4c7d5a35a2 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts @@ -0,0 +1,851 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; + +import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; + +import { + PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA, + PRP_BLOCK_TOOL_NAME, + PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, + PRP_COMPLETION_TOOL_NAME, +} from "../contracts/completion-result.js"; + +export interface RunnerToolDefinition { + name: string; + description?: string; + inputSchema: Record; +} + +export interface RunnerToolCall { + tool: string; + callId: string; + arguments: unknown; + signal: AbortSignal; +} + +export interface RunnerToolBridgeOptions { + tools?: readonly Readonly>[]; + /** Runner-owned operations that are callable but never model-visible. */ + privateTools?: readonly Readonly>[]; + handler(call: RunnerToolCall): Promise; + timeoutMs?: number; + privateToolTimeoutMs?: number; + maxBodyBytes?: number; + /** Internal test seam; production bounds headers and body reads to 30 seconds. */ + requestBodyTimeoutMs?: number; + secret?: string; +} + +export interface RunnerToolBridge { + readonly url: string; + readonly secret: string; + close(): Promise; +} + +interface AdmittedCall { + fingerprint: string; + promise: Promise; +} + +interface RunnerToolTextContent { + type: "text"; + text: string; +} + +interface RunnerToolCallResult { + content: RunnerToolTextContent[]; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_PRIVATE_TOOL_TIMEOUT_MS = 24 * 60 * 60 * 1_000; +const DEFAULT_MAX_BODY_BYTES = 1_048_576; +const DEFAULT_REQUEST_BODY_TIMEOUT_MS = 30_000; +const MAX_CALLS = 2_048; +const MAX_RESULT_CHUNK_BYTES = 64 * 1024; +const RESERVED_TOOLS: readonly RunnerToolDefinition[] = [ + { + name: PRP_COMPLETION_TOOL_NAME, + description: "Return the semantic completion result.", + inputSchema: PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, + }, + { + name: PRP_BLOCK_TOOL_NAME, + description: "Return the semantic blocked result.", + inputSchema: PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA, + }, +]; + +export function canonicalRunnerToolName(name: string): string { + const trimmed = name.trim(); + if (trimmed === PRP_COMPLETION_TOOL_NAME || trimmed === PRP_BLOCK_TOOL_NAME) { + return trimmed; + } + for (const prefix of ["paperclip__", "paperclip_", "paperclip."]) { + if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length); + } + return trimmed; +} + +/** Start one authenticated, loopback-only MCP endpoint for a closed run catalog. */ +export async function startRunnerToolBridge( + options: RunnerToolBridgeOptions, +): Promise { + const secret = options.secret ?? randomBytes(32).toString("base64url"); + if (secret.length === 0) + throw new Error("Runner tool bridge secret is empty"); + const tools = normalizeTools(options.tools ?? [], "public"); + const privateTools = normalizeTools(options.privateTools ?? [], "private"); + const publicNames = new Set(tools.map((tool) => tool.name)); + for (const tool of privateTools) { + if (publicNames.has(tool.name)) { + throw new Error( + `Runner tool ${tool.name} cannot be both public and private`, + ); + } + } + const visibleTools = [...tools, ...structuredClone(RESERVED_TOOLS)]; + const admittedTools = [...visibleTools, ...privateTools]; + const validators = compileValidators(admittedTools); + const calls = new Map(); + const controllers = new Map(); + const context = { + secret, + visibleTools, + admittedTools: new Map(admittedTools.map((tool) => [tool.name, tool])), + validators, + calls, + controllers, + handler: options.handler, + timeoutMs: positiveBoundedInteger( + options.timeoutMs, + DEFAULT_TIMEOUT_MS, + 1, + 24 * 60 * 60 * 1_000, + "tool timeout", + ), + privateToolTimeoutMs: positiveBoundedInteger( + options.privateToolTimeoutMs, + DEFAULT_PRIVATE_TOOL_TIMEOUT_MS, + 1, + 24 * 60 * 60 * 1_000, + "private tool timeout", + ), + privateToolNames: new Set(privateTools.map((tool) => tool.name)), + maxBodyBytes: positiveBoundedInteger( + options.maxBodyBytes, + DEFAULT_MAX_BODY_BYTES, + 1, + 16 * 1024 * 1024, + "request size", + ), + requestBodyTimeoutMs: positiveBoundedInteger( + options.requestBodyTimeoutMs, + DEFAULT_REQUEST_BODY_TIMEOUT_MS, + 1, + 5 * 60 * 1_000, + "request body timeout", + ), + }; + const server = createServer((request, response) => { + void handleRequest(request, response, context).catch(() => { + if (!response.headersSent) response.statusCode = 500; + if (!response.writableEnded) response.end(); + }); + }); + server.requestTimeout = context.requestBodyTimeoutMs; + server.headersTimeout = context.requestBodyTimeoutMs; + server.on("clientError", (_error, socket) => socket.destroy()); + await listenLoopback(server); + const address = server.address(); + if (address === null || typeof address === "string") { + await closeServer(server); + throw new Error("Runner tool bridge failed to bind a loopback port"); + } + let closed = false; + return Object.freeze({ + url: `http://127.0.0.1:${address.port}/mcp`, + secret, + async close() { + if (closed) return; + closed = true; + for (const controller of controllers.values()) controller.abort(); + await closeServer(server); + }, + }); +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + context: { + secret: string; + visibleTools: RunnerToolDefinition[]; + admittedTools: Map; + validators: Map; + calls: Map; + controllers: Map; + handler: RunnerToolBridgeOptions["handler"]; + timeoutMs: number; + privateToolTimeoutMs: number; + privateToolNames: ReadonlySet; + maxBodyBytes: number; + requestBodyTimeoutMs: number; + }, +): Promise { + setSecurityHeaders(response); + if (!authorized(request.headers.authorization, context.secret)) { + response.statusCode = 401; + response.end(); + return; + } + if (request.method !== "POST" || request.url !== "/mcp") { + response.statusCode = request.method === "GET" ? 405 : 404; + if (request.method === "GET") response.setHeader("Allow", "POST"); + response.end(); + return; + } + if (!isJsonContentType(request.headers["content-type"])) { + response.statusCode = 415; + response.end(); + return; + } + let message: Record; + try { + message = parseMessage( + await readBody( + request, + context.maxBodyBytes, + context.requestBodyTimeoutMs, + ), + ); + } catch (error) { + writeRpc(response, null, undefined, rpcError(-32700, safeError(error))); + return; + } + const id = message.id ?? null; + const method = typeof message.method === "string" ? message.method : ""; + if (method === "notifications/cancelled") { + const params = isRecord(message.params) ? message.params : {}; + const requestId = params.requestId; + if (typeof requestId === "string" || typeof requestId === "number") { + context.controllers.get(rpcIdKey(requestId))?.abort(); + } + response.statusCode = 202; + response.end(); + return; + } + if (method === "notifications/initialized") { + response.statusCode = 202; + response.end(); + return; + } + if (method === "initialize") { + response.setHeader("Mcp-Session-Id", randomBytes(16).toString("hex")); + writeRpc(response, id, { + protocolVersion: "2025-03-26", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "paperclip-runner", version: "1" }, + }); + return; + } + if (method === "ping") { + writeRpc(response, id, {}); + return; + } + if (method === "tools/list") { + writeRpc(response, id, { tools: context.visibleTools }); + return; + } + if (method !== "tools/call") { + writeRpc(response, id, undefined, rpcError(-32601, "Method not found")); + return; + } + + const params = isRecord(message.params) ? message.params : {}; + const rawName = typeof params.name === "string" ? params.name : ""; + const tool = canonicalRunnerToolName(rawName); + if (!context.admittedTools.has(tool)) { + writeToolError(response, id, "Unsupported tool."); + return; + } + if (typeof id !== "string" && typeof id !== "number") { + writeRpc( + response, + null, + undefined, + rpcError(-32600, "Tool calls require a request id"), + ); + return; + } + const callId = String(id); + const callKey = rpcIdKey(id); + const args = params.arguments ?? {}; + const validator = context.validators.get(tool); + if (validator === undefined || !validator(args)) { + const detail = validator?.errors + ?.map( + (error) => + `${error.instancePath || "/"} ${error.message ?? "is invalid"}`, + ) + .join("; "); + writeToolError( + response, + id, + `Invalid tool input${detail ? `: ${detail}` : "."}`, + ); + return; + } + + const fingerprint = canonicalJson({ tool, args }); + const existing = context.calls.get(callKey); + if (existing && existing.fingerprint !== fingerprint) { + writeToolError(response, id, "Duplicate call identity conflict."); + return; + } + if (!existing && context.calls.size >= MAX_CALLS) { + for (const admittedId of context.calls.keys()) { + if (context.controllers.has(admittedId)) continue; + context.calls.delete(admittedId); + break; + } + if (context.calls.size >= MAX_CALLS) { + writeToolError(response, id, "Runner tool bridge is at call capacity."); + return; + } + } + const controller = existing === undefined ? new AbortController() : undefined; + const execution: Promise = + existing?.promise ?? + withCancellationAndTimeout( + Promise.resolve() + .then(() => + context.handler({ + tool, + callId, + arguments: structuredClone(args), + signal: controller!.signal, + }), + ) + .then((result) => successfulToolResult(tool, callId, result)), + controller!, + context.privateToolNames.has(tool) + ? context.privateToolTimeoutMs + : context.timeoutMs, + ); + if (!existing) { + context.calls.set(callKey, { fingerprint, promise: execution }); + context.controllers.set(callKey, controller!); + void execution + .finally(() => context.controllers.delete(callKey)) + .catch(() => undefined); + } + try { + const result = await execution; + writeRpc(response, id, result); + } catch (error) { + writeToolError(response, id, safeError(error)); + } +} + +function normalizeTools( + input: readonly Readonly>[], + visibility: "public" | "private", +): RunnerToolDefinition[] { + const tools: RunnerToolDefinition[] = []; + const seen = new Set(); + const reservedNames = new Set(RESERVED_TOOLS.map((tool) => tool.name)); + for (const raw of input) { + const name = + typeof raw.name === "string" ? canonicalRunnerToolName(raw.name) : ""; + if (!/^[A-Za-z0-9_.:-]{1,256}$/.test(name)) { + throw new Error(`Runner ${visibility} tool has an invalid name`); + } + if (reservedNames.has(name)) { + throw new Error(`Runner tool ${name} is reserved by the protocol`); + } + if (seen.has(name)) { + throw new Error(`Runner ${visibility} tool ${name} is duplicated`); + } + if (!isRecord(raw.inputSchema)) { + throw new Error(`Runner tool ${name} requires an object input schema`); + } + tools.push({ + name, + ...(typeof raw.description === "string" + ? { description: raw.description.slice(0, 4_096) } + : {}), + inputSchema: structuredClone(raw.inputSchema), + }); + seen.add(name); + } + return tools; +} + +function compileValidators( + tools: readonly RunnerToolDefinition[], +): Map { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + return new Map( + tools.map((tool) => [tool.name, ajv.compile(tool.inputSchema)]), + ); +} + +function authorized(value: string | undefined, secret: string): boolean { + if (!value?.startsWith("Bearer ")) return false; + const supplied = Buffer.from(value.slice(7)); + const expected = Buffer.from(secret); + return ( + supplied.length === expected.length && timingSafeEqual(supplied, expected) + ); +} + +function readBody( + request: IncomingMessage, + maxBytes: number, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const cleanup = () => { + clearTimeout(timer); + request.off("data", onData); + request.off("end", onEnd); + request.off("error", onError); + request.off("aborted", onAborted); + }; + const fail = (error: Error, destroy = false) => { + if (settled) return; + settled = true; + cleanup(); + if (destroy && !request.destroyed) request.destroy(); + reject(error); + }; + const onData = (value: Buffer | string) => { + if (settled) return; + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + if (chunk.length > maxBytes - size) { + fail( + new Error("MCP request exceeded the retained payload limit"), + true, + ); + return; + } + size += chunk.length; + chunks.push(chunk); + }; + const onEnd = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(Buffer.concat(chunks).toString("utf8")); + }; + const onError = (error: Error) => fail(error); + const onAborted = () => fail(new Error("MCP request body was aborted")); + const timer = setTimeout(() => { + fail(new Error("MCP request body timed out"), true); + }, timeoutMs); + timer.unref?.(); + request.on("data", onData); + request.on("end", onEnd); + request.on("error", onError); + request.on("aborted", onAborted); + }); +} + +function parseMessage(body: string): Record { + const parsed: unknown = JSON.parse(body); + if ( + !isRecord(parsed) || + parsed.jsonrpc !== "2.0" || + typeof parsed.method !== "string" + ) { + throw new Error("Invalid JSON-RPC request"); + } + return parsed; +} + +function writeToolError( + response: ServerResponse, + id: unknown, + message: string, +): void { + writeRpc(response, id, { + isError: true, + content: [{ type: "text", text: message }], + }); +} + +function writeRpc( + response: ServerResponse, + id: unknown, + result?: unknown, + error?: unknown, +): void { + response.statusCode = 200; + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id, + ...(error === undefined ? { result } : { error }), + }), + ); +} + +function rpcError( + code: number, + message: string, +): { code: number; message: string } { + return { code, message }; +} + +function setSecurityHeaders(response: ServerResponse): void { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); +} + +function listenLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeIdleConnections(); + }); +} + +function withCancellationAndTimeout( + promise: Promise, + controller: AbortController, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + controller.signal.removeEventListener("abort", onAbort); + callback(); + }; + const onAbort = () => + finish(() => reject(new Error("Paperclip tool call cancelled"))); + const timer = setTimeout(() => { + finish(() => reject(new Error("Paperclip tool call timed out"))); + controller.abort(); + }, timeoutMs); + timer.unref(); + controller.signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => finish(() => resolve(value)), + (error) => finish(() => reject(error)), + ); + }); +} + +function safeError(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice( + 0, + 2_000, + ); +} + +function successfulToolResult( + tool: string, + callId: string, + value: unknown, +): RunnerToolCallResult { + const json = strictJson(value); + if (json !== null) return chunkedToolResult(tool, callId, "json", json); + const representation = JSON.stringify({ + schema: "paperclip.semantic_tool_result.v1", + status: "completed", + tool, + callIdentitySha256: createHash("sha256").update(callId).digest("hex"), + encoding: "paperclip.tagged_graph.v1", + result: taggedGraph(value), + }); + return chunkedToolResult( + tool, + callId, + "paperclip.tagged_graph.v1", + representation, + ); +} + +function strictJson(value: unknown): string | null { + try { + const serialized = JSON.stringify( + value, + function strictJsonValue(key, candidate) { + const source = this[key]; + if ( + typeof candidate === "undefined" || + typeof candidate === "bigint" || + typeof candidate === "function" || + typeof candidate === "symbol" || + (typeof candidate === "number" && !Number.isFinite(candidate)) || + (source !== null && + typeof source === "object" && + Reflect.ownKeys(source).some( + (property) => typeof property === "symbol", + )) || + (candidate !== null && + typeof candidate === "object" && + !Array.isArray(candidate) && + Object.getPrototypeOf(candidate) !== Object.prototype && + Object.getPrototypeOf(candidate) !== null) + ) { + throw new Error("Result requires tagged graph encoding"); + } + return candidate; + }, + ); + return serialized ?? null; + } catch { + return null; + } +} + +function chunkedToolResult( + tool: string, + callId: string, + encoding: "json" | "paperclip.tagged_graph.v1", + serialized: string, +): RunnerToolCallResult { + if (Buffer.byteLength(serialized) <= MAX_RESULT_CHUNK_BYTES) { + return { content: [{ type: "text", text: serialized }] }; + } + const chunks = utf8Chunks(serialized, MAX_RESULT_CHUNK_BYTES); + const manifest = JSON.stringify({ + schema: "paperclip.semantic_tool_result_chunks.v1", + status: "completed", + tool, + callIdentitySha256: createHash("sha256").update(callId).digest("hex"), + encoding, + contentOffset: 1, + chunkCount: chunks.length, + byteLength: Buffer.byteLength(serialized), + sha256: createHash("sha256").update(serialized).digest("hex"), + }); + return { + content: [ + { type: "text", text: manifest }, + ...chunks.map((text) => ({ type: "text" as const, text })), + ], + }; +} + +function utf8Chunks(value: string, maxBytes: number): string[] { + const bytes = Buffer.from(value); + const chunks: string[] = []; + let start = 0; + while (start < bytes.length) { + let end = Math.min(start + maxBytes, bytes.length); + while (end < bytes.length && (bytes[end]! & 0xc0) === 0x80) end -= 1; + if (end === start) { + throw new Error("Runner tool result chunk boundary is invalid"); + } + chunks.push(bytes.subarray(start, end).toString("utf8")); + start = end; + } + return chunks; +} + +interface TaggedGraphState { + nodes: Array>; + objects: Map; + symbols: Map; +} + +function taggedGraph(value: unknown): Record { + const state: TaggedGraphState = { + nodes: [], + objects: new Map(), + symbols: new Map(), + }; + return { + root: taggedValue(value, state), + nodes: state.nodes, + }; +} + +function taggedValue(value: unknown, state: TaggedGraphState): unknown { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + return { + $type: "number", + value: Number.isNaN(value) + ? "NaN" + : value === Number.POSITIVE_INFINITY + ? "Infinity" + : "-Infinity", + }; + } + if (typeof value === "undefined") return { $type: "undefined" }; + if (typeof value === "bigint") { + return { $type: "bigint", value: value.toString() }; + } + if (typeof value === "symbol") return taggedSymbol(value, state); + + const object = value as object; + const existing = state.objects.get(object); + if (existing !== undefined) return { $ref: existing }; + const id = state.nodes.length + 1; + state.objects.set(object, id); + const node: Record = { + id, + type: taggedObjectType(value), + }; + state.nodes.push(node); + if (typeof value === "function") { + node.source = Function.prototype.toString.call(value); + } else if (value instanceof Date) { + node.value = Number.isNaN(value.getTime()) + ? "Invalid Date" + : value.toISOString(); + } else if (value instanceof RegExp) { + node.source = value.source; + node.flags = value.flags; + node.lastIndex = value.lastIndex; + } else if (value instanceof Map) { + node.entries = [...value.entries()].map(([key, entry]) => [ + taggedValue(key, state), + taggedValue(entry, state), + ]); + } else if (value instanceof Set) { + node.entries = [...value.values()].map((entry) => + taggedValue(entry, state), + ); + } else if (value instanceof ArrayBuffer) { + node.base64 = Buffer.from(value).toString("base64"); + } else if (ArrayBuffer.isView(value)) { + node.base64 = Buffer.from( + value.buffer, + value.byteOffset, + value.byteLength, + ).toString("base64"); + } + node.properties = Reflect.ownKeys(value).map((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + return { + key: typeof key === "string" ? key : taggedSymbol(key, state), + enumerable: descriptor.enumerable, + configurable: descriptor.configurable, + ...(Object.hasOwn(descriptor, "value") + ? { + writable: descriptor.writable, + value: taggedValue(descriptor.value, state), + } + : { + get: + descriptor.get === undefined + ? null + : Function.prototype.toString.call(descriptor.get), + set: + descriptor.set === undefined + ? null + : Function.prototype.toString.call(descriptor.set), + }), + }; + }); + return { $ref: id }; +} + +function taggedSymbol( + value: symbol, + state: TaggedGraphState, +): Record { + const existing = state.symbols.get(value); + if (existing !== undefined) return { $symbolRef: existing }; + const id = state.symbols.size + 1; + state.symbols.set(value, id); + return { + $type: "symbol", + id, + key: Symbol.keyFor(value) ?? null, + description: value.description ?? null, + }; +} + +function taggedObjectType(value: object): string { + if (Array.isArray(value)) return "Array"; + if (typeof value === "function") return "Function"; + if (ArrayBuffer.isView(value)) return value.constructor.name; + const prototype = Object.getPrototypeOf(value) as { + constructor?: { name?: unknown }; + } | null; + return typeof prototype?.constructor?.name === "string" + ? prototype.constructor.name + : "Object"; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function isJsonContentType(value: string | undefined): boolean { + return value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; +} + +function rpcIdKey(value: string | number): string { + return `${typeof value}:${String(value)}`; +} + +function positiveBoundedInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, + label: string, +): number { + const resolved = value ?? fallback; + if ( + !Number.isSafeInteger(resolved) || + resolved < minimum || + resolved > maximum + ) { + throw new Error( + `Runner tool bridge ${label} is outside its supported bound`, + ); + } + return resolved; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}