UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

244 lines (243 loc) 8.54 kB
import { randomBytes, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; //#region src/tool-bridge.ts /** * MCP tool-proxy bridge, shared by all harness adapters. * * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server. * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the * orchestrator process (with its closures / DB / secrets), and the result is * returned into the sandbox. * * The bridge is split into a transport-agnostic CORE and a TRANSPORT: * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver * (no I/O). It is what makes the bridge portable. * - {@link startHostToolBridge} is the `node:http` transport for a long-running * host (laptop / CI / Docker orchestrator). It binds loopback unless the * sandbox must reach it via `host.docker.internal`, and authenticates with a * constant-time bearer check. * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the * SAME core from its own `fetch` handler — no raw TCP listener — see * {@link handleBridgeJsonRpc} and the Cloudflare example. */ /** * Name of the bridged MCP server. The agent sees tools as * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix * so tool-call events match the names the application registered. */ var BRIDGED_MCP_SERVER_NAME = "tanstack"; /** Hostname the sandbox uses to reach the bridge endpoint, per provider. */ function hostForSandbox(provider) { return provider === "docker" || provider === "sbx" ? "host.docker.internal" : "127.0.0.1"; } /** * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises, * substituting an empty object schema when it isn't already a JSON-schema object * (project rule: a guard, not an `as` cast). */ function toObjectSchema(schema) { if (schema !== null && typeof schema === "object" && "type" in schema && schema.type === "object") return { ...schema, type: "object" }; return { type: "object", properties: {} }; } /** Build the transport-agnostic bridge core for the given tools. */ function createToolBridgeCore(tools, options = {}) { const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); const permission = options.permission; const permissionDescriptor = permission ? { name: permission.toolName, description: "Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.", inputSchema: { type: "object", properties: {} } } : void 0; return { listTools() { return [...tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: toObjectSchema(tool.inputSchema) })), ...permissionDescriptor ? [permissionDescriptor] : []]; }, async callTool(name, args) { if (permission && name === permission.toolName) { const result = await permission.resolve(args ?? {}); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } const tool = toolsByName.get(name); if (!tool?.execute) throw new Error(`Unknown tool: ${name}`); try { const result = await tool.execute(args ?? {}, { context: options.context, abortSignal: options.signal, emitCustomEvent: options.emitCustomEvent ?? (() => {}) }); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }] }; } catch (error) { return { isError: true, content: [{ type: "text", text: `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` }] }; } } }; } /** * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list` * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC * response object, or `null` for a notification (no `id`). */ async function handleBridgeJsonRpc(core, message) { if (message === null || typeof message !== "object") return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request" } }; const rpc = message; const id = rpc.id ?? null; const respond = (result) => ({ jsonrpc: "2.0", id, result }); switch (rpc.method) { case "initialize": return respond({ protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: "1.0.0" } }); case "notifications/initialized": return null; case "tools/list": return respond({ tools: core.listTools() }); case "tools/call": { const params = rpc.params ?? {}; if (typeof params.name !== "string") return { jsonrpc: "2.0", id, error: { code: -32602, message: "Invalid params: name" } }; return respond(await core.callTool(params.name, params.arguments ?? {})); } default: return { jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } }; } } /** * Constant-time check of an `Authorization: Bearer <token>` header against the * expected token. Length mismatch returns false early (token length is not * secret); equal-length comparison is timing-safe. */ function timingSafeBearerEqual(header, token) { if (header === void 0) return false; const a = Buffer.from(header); const b = Buffer.from(`Bearer ${token}`); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } function buildMcpServer(core) { const server = new McpServer({ name: BRIDGED_MCP_SERVER_NAME, version: "1.0.0" }, { capabilities: { tools: {} } }); server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: core.listTools() })); server.server.setRequestHandler(CallToolRequestSchema, async (request) => { const result = await core.callTool(request.params.name, request.params.arguments ?? {}); return { content: result.content, ...result.isError ? { isError: true } : {} }; }); return server; } /** * Start the `node:http` MCP tool-proxy bridge for the given tools. For a * long-running host (laptop / CI / Docker orchestrator). Serverless/edge * orchestrators serve {@link createToolBridgeCore} from their own `fetch` * handler instead. */ async function startHostToolBridge(tools, options) { const token = randomBytes(24).toString("hex"); const core = createToolBridgeCore(tools, options); const bindAddress = options.bindAddress ?? (options.hostForSandbox === "host.docker.internal" ? "0.0.0.0" : "127.0.0.1"); const httpServer = createServer((req, res) => { (async () => { if (!timingSafeBearerEqual(req.headers["authorization"], token)) { res.writeHead(401).end("unauthorized"); return; } const server = buildMcpServer(core); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 }); res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); let body = ""; for await (const chunk of req) body += chunk; let parsed; try { parsed = body ? JSON.parse(body) : void 0; } catch { if (!res.headersSent) res.writeHead(400).end("invalid JSON body"); return; } await transport.handleRequest(req, res, parsed); })().catch((error) => { console.error("[tool-bridge] request handler failed:", error); if (!res.headersSent) res.writeHead(500).end("bridge error"); }); }); await new Promise((resolve) => httpServer.listen(0, bindAddress, resolve)); const port = httpServer.address().port; return { name: BRIDGED_MCP_SERVER_NAME, url: `http://${options.hostForSandbox}:${port}/mcp`, token, close: () => new Promise((resolve) => httpServer.close(() => resolve())) }; } /** Default provisioner: a `node:http` listener on the host. */ var nodeHttpBridgeProvisioner = { provision(tools, options) { const { provider, ...core } = options; return startHostToolBridge(tools, { hostForSandbox: hostForSandbox(provider), ...core }); } }; //#endregion export { BRIDGED_MCP_SERVER_NAME, createToolBridgeCore, handleBridgeJsonRpc, hostForSandbox, nodeHttpBridgeProvisioner, startHostToolBridge, timingSafeBearerEqual }; //# sourceMappingURL=tool-bridge.js.map