Skip to content

Repository files navigation

codemode

Two MCP tools that replace hundreds. Give an AI agent your OpenAPI spec and a request handler — it discovers and calls your entire API by writing JavaScript in a sandboxed runtime.

Instead of defining individual MCP tools for every API endpoint (list-pods, create-product, get-logs, ...), CodeMode exposes just two tools:

  • search — the agent writes JS to filter your OpenAPI spec and discover endpoints
  • execute — the agent writes JS to call your API via an injected client

This is the same pattern Cloudflare uses to expose 2,500+ API endpoints through just two MCP tools, reducing context window usage by 99.9%.

Try It

Requires mise for tooling (Node.js, pnpm, Task):

git clone https://github.com/akua-dev/codemode.git
cd codemode
mise install # installs Node 24, pnpm 10, Task
task install # installs dependencies
task example # runs the Petstore demo

Fetches the real Petstore OpenAPI spec from the web, then runs search + execute against a local Hono mock — no API keys needed.

Install

pnpm add @robinbraemer/codemode
# Install a sandbox runtime (at least one):
pnpm add @robinbraemer/llrt # Native LLRT — default candidate
pnpm add isolated-vm # V8 isolates — data-only Node.js fallback

If both are installed, the auto-selector (createExecutor) picks native LLRT first, then isolated-vm on Node for data-only execution. Request-capable execution requires LLRT. QuickJSExecutor is still exported for explicit advanced use, but it is not selected automatically because its host-callback bridge cannot enforce all byte limits before values cross into host JavaScript.

Quick Start

import{CodeMode}from'@robinbraemer/codemode';import{Hono}from'hono';constapp=newHono();app.get('/v1/clusters',(c)=>c.json([{id: '1',name: 'prod'}]));app.post('/v1/clusters',async(c)=>{constbody=awaitc.req.json();returnc.json({id: '2', ...body},201);});constcodemode=newCodeMode({spec: myOpenAPISpec,// OpenAPI 3.x spec, or async getterrequest: app.request.bind(app),// in-process, no network hop});// The agent searches the spec to discover endpoints...constsearch=awaitcodemode.callTool('search',{code: `async () => { const results = []; for (const [path, methods] of Object.entries(spec.paths)) { for (const [method, op] of Object.entries(methods)) { if (op.tags?.some(t => t.toLowerCase() === 'clusters')) { results.push({ method: method.toUpperCase(), path, summary: op.summary }); } } } return results; }`});// ...then executes API callsconstresult=awaitcodemode.callTool('execute',{code: `async () => { const res = await api.request({ method: "GET", path: "/v1/clusters" }); return res.body; }`});

MCP Server Integration

import{McpServer}from'@modelcontextprotocol/sdk/server/mcp.js';import{StdioServerTransport}from'@modelcontextprotocol/sdk/server/stdio.js';import{CodeMode}from'@robinbraemer/codemode';import{registerTools}from'@robinbraemer/codemode/mcp';constcodemode=newCodeMode({spec: ()=>fetchOpenAPISpec(),request: app.request.bind(app),});constserver=newMcpServer({name: 'my-api',version: '1.0.0'});registerTools(codemode,server);consttransport=newStdioServerTransport();awaitserver.connect(transport);

How It Works

AI Agent
│ writes JavaScript code
▼
CodeMode MCP Server
│
├─ search(code) → runs JS with preprocessed OpenAPI spec
│ → ref-resolved paths view with only essential fields kept
│ → agent discovers endpoints, schemas, parameters
│
└─ execute(code) → runs JS with injected request client
→ api.request() calls your handler in-process
→ no network hop, auth handled automatically

All code runs in a fresh sandbox runtime. The sandbox has zero I/O by default — no require, no process, no fetch, no filesystem. Request-capable execution uses injected host callbacks (spec for search, {namespace}.request() for execute) and is supported by LLRT.

Each tool call gets a fresh sandbox with no state carried over between calls.

API

new CodeMode(options)

OptionTypeDefaultDescription
specOpenAPISpec | () => OpenAPISpec | Promise<OpenAPISpec>requiredOpenAPI 3.x spec or async getter
request(input, init?) => ResponserequiredFetch-compatible handler (app.request.bind(app) for Hono)
namespacestring"api"Client name in sandbox (api.request(...)). Must be a valid JS identifier, not a reserved name.
baseUrlstring"http://localhost"Base URL for relative paths
sandboxSandboxOptionssee belowSandbox resource limits
executorExecutorcreateExecutor()Custom sandbox executor
maxResponseTokensnumber25000Token limit for response truncation (0 to disable)
maxRequestsnumber50Max requests per execute() call
maxResponseBytesnumber10485760Max response body size in bytes (10MB)
allowedHeadersstring[]undefinedHeader whitelist. When unset, a blocklist strips Authorization, Cookie, Host, X-Forwarded-*, Proxy-*.
maxRefDepthnumber50Max $ref resolution depth

Successful spec preprocessing is cached. Concurrent searches share an in-flight async spec provider, while a failed preparation is retried by a later search.

Data-only input first has a 100,000-node structural inspection limit. Built-in executors additionally reject cycles, bigint, and custom toJSON hooks, then preflight the fully expanded transport shape before marshalling it into a sandbox. Shared aliases count once per transport occurrence, with limits of 500,000 expanded occurrences and 10 MiB of encoded input. Custom executors still receive the alias-preserving structured clone and remain responsible for their own transport limits.

SandboxOptions

OptionTypeDefaultDescription
memoryMBnumber64Sandbox heap memory limit
timeoutMsnumber30000CPU timeout in ms (caps pure compute)
wallTimeMsnumber60000Wall-clock timeout in ms (caps total elapsed time including async I/O)

Methods

codemode.tools(): ToolDefinition[]

Returns MCP-compatible tool definitions for search and execute.

codemode.callTool(name, { code }): Promise<ToolCallResult>

Route a tool call. Returns { content: [{ type: "text", text }], isError? }.

codemode.search(code): Promise<ToolCallResult>

Run search code directly (shorthand for callTool('search', { code })).

codemode.execute(code): Promise<ToolCallResult>

Run execute code directly (shorthand for callTool('execute', { code })).

codemode.setToolNames(search, execute): this

Override default tool names. Useful when running multiple CodeMode instances.

codemode.dispose(): void

Clean up sandbox resources.

Sandbox API

Inside search

The spec global is the preprocessed OpenAPI paths view; resolvable $ref pointers are expanded inline:

// Find endpoints by tagasync()=>{constresults=[];for(const[path,methods]ofObject.entries(spec.paths)){for(const[method,op]ofObject.entries(methods)){if(op.tags?.some(t=>t.toLowerCase()==='clusters')){results.push({method: method.toUpperCase(), path,summary: op.summary});}}}returnresults;}// Get endpoint with requestBody schema (resolvable refs are expanded)async()=>{constop=spec.paths['/v1/products']?.post;return{summary: op?.summary,requestBody: op?.requestBody};}// Spec shapeasync()=>({endpoints: Object.keys(spec.paths).length,})

Inside execute

The {namespace}.request() function makes API calls through the host handler:

// GET with query paramsasync()=>{constres=awaitapi.request({method: "GET",path: "/v1/clusters",query: {limit: 10},});returnres.body;}// POST with bodyasync()=>{constres=awaitapi.request({method: "POST",path: "/v1/products",body: {name: "Redis",chart: "bitnami/redis"},});return{status: res.status,body: res.body};}// Chain callsasync()=>{constlist=awaitapi.request({method: "GET",path: "/v1/clusters"});constdetails=awaitPromise.all(list.body.map(c=>api.request({method: "GET",path: `/v1/clusters/${c.id}`})));returndetails.map(d=>d.body);}

Request options:

FieldTypeDescription
methodstringHTTP method ("GET", "POST", etc.)
pathstringAPI path ("/v1/clusters")
queryRecord<string, string | number | boolean>Query parameters (optional)
bodyunknownRequest body, auto-serialized as JSON (optional)
headersRecord<string, string>Additional headers (optional)

Response:{ status: number, headers: Record<string, string>, body: unknown }

Spec Preprocessing

CodeMode automatically preprocesses your OpenAPI spec before passing it to the search sandbox:

  • $ref resolution — resolvable $ref pointers are expanded inline; circular refs become { $circular: ref }, and refs beyond maxRefDepth become { $circular: ref, $reason: "max depth exceeded" }
  • Field extraction — only essential fields kept per operation: summary, description, tags, parameters, requestBody, responses
  • Output shape — only { paths } is passed to search; info, servers, and components are omitted because operation fields and resolved references are represented in the paths view

You can also use the preprocessing utilities directly:

import{resolveRefs,processSpec,extractTags}from'@robinbraemer/codemode';constprocessed=processSpec(rawSpec);consttags=extractTags(rawSpec);

Executors

CodeMode ships three executor backends. LlrtNativeExecutor is the default and the only request-capable backend. IsolatedVMExecutor is the Node.js data-only fallback, and QuickJSExecutor is an explicit data-only compatibility backend.

Use createExecutor() for automatic selection, or pass an executor instance explicitly:

import{CodeMode,createExecutor,IsolatedVMExecutor,LlrtNativeExecutor}from'@robinbraemer/codemode';// Automatic — picks native LLRT first, then data-only isolated-vm on Nodeconstcodemode=newCodeMode({
spec,request: handler,executor: awaitcreateExecutor({memoryMB: 128,timeoutMs: 60_000}),});// Or explicitconstcodemode=newCodeMode({
spec,request: handler,executor: newIsolatedVMExecutor({memoryMB: 128,timeoutMs: 60_000,// CPU time limitwallTimeMs: 120_000,// total elapsed time limit}),});
ExecutorPackagePerformancePortabilityProduction-ready
LlrtNativeExecutor@robinbraemer/llrtLightweight native LLRTNode.js✅ default candidate
IsolatedVMExecutorisolated-vmNative V8 speedNode.js⚠️ data-only fallback
QuickJSExecutorquickjs-emscriptenSlower (interpreted WASM)Node, Bun, CF Workers, browser⚠️ explicit only — see caveats

QuickJSExecutor caveats

  • Not a production backend and not auto-selected. Use this only by constructing new QuickJSExecutor(...) explicitly. Production request-capable callers should use LlrtNativeExecutor.
  • Host callbacks are disabled. QuickJS cannot enforce host-call byte limits before guest values are dumped into host JavaScript, and sequential host awaits still hit upstream release-asyncify crashes. QuickJSExecutor now fails closed when function globals are provided.
  • Return-value semantics differ from isolated-vm. Final values cross via a JSON.stringify envelope. Date, Map, Set, BigInt are converted to strings/objects, not preserved as instances. isolated-vm uses structured clone and preserves them. Stick to plain JSON-safe shapes in sandboxed code that targets both backends.
  • CPU timeout is wall-clock-based.isolated-vm uses true CPU time; QuickJS uses elapsed time.

Custom Executor

Implement the Executor interface to use your own sandbox:

CodeMode passes a fresh structured clone of the processed spec to each search() call, so mutations made by a custom executor cannot change later searches.

CodeMode calls executeData() for search() and executeWithCapabilities() for execute(). Implement the latter when the custom runtime needs to expose the request capability; the legacy execute() method remains available for direct executor use.

import{CodeMode,emptyExecuteStats,typeCapabilityManifest,typeExecuteResult,typeExecutor,}from'@robinbraemer/codemode';classMyExecutorimplementsExecutor{asyncexecuteData(_code: string,_input: Record<string,unknown>): Promise<ExecuteResult>{// Run data-only code in the sandbox.return{result: undefined,stats: emptyExecuteStats()};}asyncexecuteWithCapabilities(_code: string,_input: Record<string,unknown>,_capabilities: CapabilityManifest,): Promise<ExecuteResult>{// Run code with declared capabilities, such as `{namespace}.request()`.return{result: undefined,stats: emptyExecuteStats()};}asyncexecute(_code: string,_globals: Record<string,unknown>): Promise<ExecuteResult>{// Legacy direct-executor entrypoint.return{result: undefined,stats: emptyExecuteStats()};}dispose(){/* clean up */}}constcodemode=newCodeMode({
spec,request: handler,executor: newMyExecutor(),});

Token Efficiency

ApproachContext Tokens
Individual MCP tools (15-50+ tools)~15,000-50,000+
Full OpenAPI spec in context~1,000,000+
CodeMode (2 tools)~1,000

License

MIT

About

Programmatic tool calling / Code Mode for MCP — turn any OpenAPI spec into two sandboxed tools (search + execute).

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages