mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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
This commit is contained in:
1 parent
db52ec0ca0
commit
9036d3c484
2 files changed
+1298
No files matched your search
@@ -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<string, unknown>,
|
||||||
|
secret = bridge.secret,
|
||||||
|
): Promise<Response> {
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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<void>((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<Record<string, unknown>> {
|
||||||
|
return { name, inputSchema: { type: "object" } };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerToolCall {
|
||||||
|
tool: string;
|
||||||
|
callId: string;
|
||||||
|
arguments: unknown;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerToolBridgeOptions {
|
||||||
|
tools?: readonly Readonly<Record<string, unknown>>[];
|
||||||
|
/** Runner-owned operations that are callable but never model-visible. */
|
||||||
|
privateTools?: readonly Readonly<Record<string, unknown>>[];
|
||||||
|
handler(call: RunnerToolCall): Promise<unknown>;
|
||||||
|
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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdmittedCall {
|
||||||
|
fingerprint: string;
|
||||||
|
promise: Promise<RunnerToolCallResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RunnerToolBridge> {
|
||||||
|
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<string, AdmittedCall>();
|
||||||
|
const controllers = new Map<string, AbortController>();
|
||||||
|
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<string, RunnerToolDefinition>;
|
||||||
|
validators: Map<string, ValidateFunction>;
|
||||||
|
calls: Map<string, AdmittedCall>;
|
||||||
|
controllers: Map<string, AbortController>;
|
||||||
|
handler: RunnerToolBridgeOptions["handler"];
|
||||||
|
timeoutMs: number;
|
||||||
|
privateToolTimeoutMs: number;
|
||||||
|
privateToolNames: ReadonlySet<string>;
|
||||||
|
maxBodyBytes: number;
|
||||||
|
requestBodyTimeoutMs: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<RunnerToolCallResult> =
|
||||||
|
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<Record<string, unknown>>[],
|
||||||
|
visibility: "public" | "private",
|
||||||
|
): RunnerToolDefinition[] {
|
||||||
|
const tools: RunnerToolDefinition[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, ValidateFunction> {
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
server.closeIdleConnections();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCancellationAndTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
controller: AbortController,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<T> {
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
objects: Map<object, number>;
|
||||||
|
symbols: Map<symbol, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taggedGraph(value: unknown): Record<string, unknown> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user