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.

402 lines (374 loc) 14 kB
/** * 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. */ import { createServer } from 'node:http' import { randomBytes, timingSafeEqual } from 'node:crypto' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import type { AddressInfo } from 'node:net' import type { AnyTool } from '@tanstack/ai' /** * 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. */ export const BRIDGED_MCP_SERVER_NAME = 'tanstack' /** Hostname the sandbox uses to reach the bridge endpoint, per provider. */ export function hostForSandbox(provider: string): string { return provider === 'docker' || provider === 'sbx' ? 'host.docker.internal' : '127.0.0.1' } /** Result of a permission decision returned to the harness's prompt tool. */ export interface PermissionToolResult { behavior: 'allow' | 'deny' message?: string updatedInput?: unknown } export interface BridgePermission { toolName: string resolve: (input: { tool_name?: string input?: unknown }) => PermissionToolResult | Promise<PermissionToolResult> } export interface ToolBridgeCoreOptions { /** Runtime context forwarded to each tool's `execute()`. */ context?: unknown /** Abort signal forwarded to each tool's `execute()`. */ signal?: AbortSignal /** * Forwarded to each tool's `execute()` so a bridged tool can stream progress / * custom events back to the client mid-execution (e.g. code mode's * `code_mode:console` logs). Without it those events are silently dropped — the * bridge runs out-of-band from the main tool executor, so the executor's own * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies * one that injects a CUSTOM chunk into its live output stream. */ emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void /** * Optional permission-prompt tool (e.g. for Claude Code's * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool * `<name>` whose handler returns the orchestrator's allow/deny decision. */ permission?: BridgePermission } /** An MCP tool descriptor as advertised to the in-sandbox agent. */ export interface ToolDescriptor { name: string description?: string inputSchema: { type: 'object'; [key: string]: unknown } } /** * 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: unknown): { type: 'object' [key: string]: unknown } { if ( schema !== null && typeof schema === 'object' && 'type' in schema && schema.type === 'object' ) { return { ...schema, type: 'object' } } return { type: 'object', properties: {} } } /** MCP `tools/call` result shape. */ export interface ToolCallResult { content: Array<{ type: 'text'; text: string }> isError?: boolean } /** * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a * `fetch` handler) wraps this and owns I/O + the bearer check. */ export interface ToolBridgeCore { listTools: () => Array<ToolDescriptor> callTool: (name: string, args: unknown) => Promise<ToolCallResult> } /** Build the transport-agnostic bridge core for the given tools. */ export function createToolBridgeCore( tools: Array<AnyTool>, options: ToolBridgeCoreOptions = {}, ): ToolBridgeCore { const toolsByName = new Map(tools.map((tool) => [tool.name, tool])) const permission = options.permission const permissionDescriptor: ToolDescriptor | undefined = permission ? { name: permission.toolName, description: 'Permission prompt: returns {behavior:"allow"|"deny"} for a requested action.', inputSchema: { type: 'object', properties: {} }, } : undefined 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: unknown = await tool.execute(args ?? {}, { context: options.context, abortSignal: options.signal, // No-op default so tools that always call it (e.g. code mode) don't // crash when the transport didn't wire a sink. emitCustomEvent: options.emitCustomEvent ?? (() => {}), }) const text = typeof result === 'string' ? result : JSON.stringify(result) return { content: [{ type: 'text', text }] } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { isError: true, content: [ { type: 'text', text: `Tool execution failed: ${message}` }, ], } } }, } } /** * 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`). */ export async function handleBridgeJsonRpc( core: ToolBridgeCore, message: unknown, ): Promise<unknown> { if (message === null || typeof message !== 'object') { return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' }, } } const rpc = message as { id?: unknown; method?: unknown; params?: unknown } const id = rpc.id ?? null const respond = (result: unknown): unknown => ({ 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 ?? {}) as { name?: unknown arguments?: unknown } 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. */ export function timingSafeBearerEqual( header: string | undefined, token: string, ): boolean { if (header === undefined) return false const a = Buffer.from(header) const b = Buffer.from(`Bearer ${token}`) if (a.length !== b.length) return false return timingSafeEqual(a, b) } export interface HostToolBridge { /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */ name: string /** URL the SANDBOX uses to reach this bridge. */ url: string /** Per-run bearer token gating the endpoint. */ token: string close: () => Promise<void> } export interface StartBridgeOptions extends ToolBridgeCoreOptions { /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */ hostForSandbox: string /** * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is * widened to `0.0.0.0` only when the sandbox reaches the host via * `host.docker.internal` (a container can't reach the host's loopback). */ bindAddress?: string } function buildMcpServer(core: ToolBridgeCore): McpServer { 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. */ export async function startHostToolBridge( tools: Array<AnyTool>, options: StartBridgeOptions, ): Promise<HostToolBridge> { const token = randomBytes(24).toString('hex') const core = createToolBridgeCore(tools, options) // Loopback by default; widen to all interfaces only for the Docker bridge, // which a container reaches via host.docker.internal (host gateway). const bindAddress = options.bindAddress ?? (options.hostForSandbox === 'host.docker.internal' ? '0.0.0.0' : '127.0.0.1') const httpServer = createServer((req, res) => { void (async () => { if (!timingSafeBearerEqual(req.headers['authorization'], token)) { res.writeHead(401).end('unauthorized') return } const server = buildMcpServer(core) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }) res.on('close', () => { void transport.close() void server.close() }) await server.connect(transport) let body = '' for await (const chunk of req) body += chunk let parsed: unknown try { parsed = body ? JSON.parse(body) : undefined } catch { // Malformed agent request → 400, distinct from an internal 500. if (!res.headersSent) res.writeHead(400).end('invalid JSON body') return } await transport.handleRequest(req, res, parsed) })().catch((error: unknown) => { // Log the underlying fault — on the host/Docker path there is no run-log // capturing it, so swallowing it leaves an operator with nothing. console.error('[tool-bridge] request handler failed:', error) if (!res.headersSent) res.writeHead(500).end('bridge error') }) }) await new Promise<void>((resolve) => httpServer.listen(0, bindAddress, resolve), ) const port = (httpServer.address() as AddressInfo).port const url = `http://${options.hostForSandbox}:${port}/mcp` return { name: BRIDGED_MCP_SERVER_NAME, url, token, close: () => new Promise<void>((resolve) => httpServer.close(() => resolve())), } } /** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */ export type ProvisionedBridge = HostToolBridge export interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions { /** Sandbox provider name, to derive how the sandbox reaches the bridge. */ provider: string } /** * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge * portable across runtimes: a harness adapter asks its capability context for a * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host / * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc} * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP * listener is needed. */ export interface ToolBridgeProvisioner { provision: ( tools: Array<AnyTool>, options: ToolBridgeProvisionOptions, ) => Promise<ProvisionedBridge> } /** Default provisioner: a `node:http` listener on the host. */ export const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = { provision(tools, options) { const { provider, ...core } = options return startHostToolBridge(tools, { hostForSandbox: hostForSandbox(provider), ...core, }) }, }