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:
parent
db52ec0ca0
commit
9036d3c484
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue