Code Execution - Overview
What is Code Execution?
The code_execution tool enables LLM agents to orchestrate multiple upstream MCP tools in a single request using JavaScript or TypeScript. Instead of making multiple round-trips to the model, you can execute complex multi-step workflows with conditional logic, loops, and data transformations—all within a single execution context.
TypeScript support: Set language: "typescript" to write code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution with near-zero overhead (<5ms).
Stored scripts: Instead of sending the source inline on every call, keep the workflow in <config-dir>/scripts/<name>.js and invoke it with script: "<name>". See Stored Scripts.
When to Use Code Execution
✅ Use code_execution when:
- You need to call 2+ tools and combine their results
- You need conditional logic based on tool responses
- You need to transform or aggregate data from multiple sources
- You need to iterate over data and call tools for each item
- You need to handle errors gracefully with fallbacks
❌ Don't use code_execution when:
- You're calling a single tool (use
call_tooldirectly) - The workflow is simple and linear (use sequential tool calls)
- You need long-running operations (>2 minutes)
- You need access to filesystem, network, or Node.js modules
Key Benefits
1. Reduced Latency
Execute multiple tool calls in a single request, eliminating the network round-trips between agent and model.
Before (3 round-trips):
Agent → Model: "Get user data"
Model → Agent: call_tool(github, get_user, {username: "octocat"})
Agent → Model: "Here's the user data"
Model → Agent: call_tool(github, list_repos, {user: "octocat"})
Agent → Model: "Here are the repos"
Model → Agent: call_tool(github, get_repo, {repo: "Hello-World"})
After (1 round-trip):
Agent → Model: "Get user and their repos"
Model → Agent: code_execution({code: "...", input: {...}})
2. Complex Logic
Implement conditional branching, loops, and error handling that would require multiple model invocations.
// Wrap the body in an IIFE so early-exit `return`s are legal — a bare
// top-level `return` is a SyntaxError. The IIFE's value is the result.
(() => {
// Conditional logic
const user = call_tool('github', 'get_user', {username: input.username});
if (!user.ok) {
return {error: 'User not found'};
}
// Loop with accumulation
const results = [];
for (const repoName of input.repos) {
const repo = call_tool('github', 'get_repo', {name: repoName});
if (repo.ok) {
results.push(repo.result);
}
}
return {repos: results, count: results.length};
})();
3. Data Transformation
Transform, filter, and aggregate data from multiple tool calls before returning results.
(() => {
const repos = call_tool('github', 'list_repos', {user: input.username});
if (!repos.ok) return repos;
// Filter and transform
const activeRepos = repos.result
.filter(r => !r.archived && r.pushed_at > input.since)
.map(r => ({name: r.name, stars: r.stargazers_count, language: r.language}));
return {repos: activeRepos, total: activeRepos.length};
})();
How It Works
Architecture
┌─────────────────────────────────────────────┐
│ LLM Agent │
│ - Receives code_execution tool description │
│ - Writes JavaScript to orchestrate tools │
└────────────┬────────────────────────────────┘
│
│ code_execution request
│ {code, input, options}
▼
┌─────────────────────────────────────────────┐
│ MCPProxy Server │
│ - Validates request and options │
│ - Checks if feature is enabled │
└────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ JavaScript Runtime Pool │
│ - Acquires VM from pool (blocks if full) │
│ - Creates isolated sandbox │
│ - Binds input, call_tool(), call_tools() │
└────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ JavaScript Execution │
│ - Runs code with timeout watchdog │
│ - Enforces max_tool_calls limit │
│ - Restricts to allowed_servers │
│ - Returns JSON-serializable result │
└────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Upstream Tool Calls │
│ - Forwards call_tool() to upstream servers │
│ - Respects quarantine and security rules │
│ - Returns {ok, result} or {ok, error} │
└─────────────────────────────────────────────┘
Execution Flow
- Request Parsing: Extract
code(orscript),input, andoptionsfrom the request - Source Resolution: Enforce exactly-one-of
code/script; for ascript, read the stored file and derive its language (see Stored Scripts) - Validation: Verify timeout (1-600000ms) and max_tool_calls (>= 0)
- Pool Acquisition: Acquire a JavaScript VM from the pool (blocks if all VMs are in use)
- Sandbox Setup: Create isolated environment with
inputglobal and thecall_tool()/call_tools()functions - Execution: Run JavaScript with timeout enforcement and tool call tracking
- Result Extraction: Validate result is JSON-serializable and return structured response
- Pool Release: Return VM to pool for reuse
- Response: Return
{ok: true, value: <result>}or{ok: false, error: {...}}
Security Model
Sandbox Restrictions
The JavaScript execution environment is heavily sandboxed to prevent security issues:
❌ Not Available:
require()- No module loadingsetTimeout()/setInterval()- No timers- Filesystem access - No
fsmodule - Network access - No
httporfetch - Environment variables - No
process.env - Node.js built-ins - JavaScript standard library only
✅ Available:
input- Global variable with request input datacall_tool(serverName, toolName, args)- Function to call upstream MCP toolscall_tools(requests, options)- Function to call independent upstream tools in parallel (see Pattern 5)- Modern JavaScript (ES2020+) standard library including Array, Object, String, Math, Date, JSON, Map, Set, Symbol, Promise, Proxy, Reflect
Configuration & Limits
{
"enable_code_execution": false, // Must be explicitly enabled (default: false)
"code_execution_timeout_ms": 120000, // Default: 2 minutes, max: 10 minutes
"code_execution_max_tool_calls": 0, // Default: unlimited
"code_execution_pool_size": 10, // Default: 10 concurrent VMs
"code_execution_max_parallel": 8 // Default: 8 concurrent calls per call_tools() batch (1-32)
}
code_execution_max_parallel is hot-reloaded and applies to executions that start after the change. It is not a request-level option: a script overrides it per batch with call_tools(requests, {max_parallel}), so precedence is per-batch override > code_execution_max_parallel > built-in 8.
Per-Request Overrides:
{
"code": "...",
"input": {...},
"options": {
"timeout_ms": 60000, // Override timeout for this request
"max_tool_calls": 20, // Limit tool calls for this request
"allowed_servers": ["github"] // Restrict to specific servers
}
}
Quarantine Integration
Code execution respects existing MCPProxy security features:
- Quarantined servers cannot be called via
call_tool() - Server enable/disable settings are enforced
- Authentication requirements are preserved
Getting Started
1. Enable the Feature
Edit your configuration file (~/.mcpproxy/mcp_config.json):
{
"enable_code_execution": true,
"mcpServers": [
{
"name": "github",
"url": "https://api.github.com/mcp",
"protocol": "http",
"enabled": true
}
]
}
2. Restart MCPProxy
pkill mcpproxy
mcpproxy serve
3. Test with CLI
# Simple JavaScript test
mcpproxy code exec --code="({ result: input.value * 2 })" --input='{"value": 21}'
# TypeScript test
mcpproxy code exec --language typescript --code="const x: number = 42; ({ result: x })"
# Call upstream tool
mcpproxy code exec --code="call_tool('github', 'get_user', {username: input.user})" --input='{"user":"octocat"}'
4. Use from LLM Agent
The code_execution tool will appear in the tools list when an LLM agent connects to MCPProxy:
{
"name": "code_execution",
"description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools...",
"inputSchema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "JavaScript or TypeScript source code..."},
"script": {"type": "string", "description": "Name of a STORED script to execute instead of sending code inline (Spec 097)..."},
"language": {"type": "string", "enum": ["javascript", "typescript"], "description": "Source language; defaults to javascript. TypeScript types are stripped before execution (GA, Spec 033 FR-001)."},
"input": {"type": "object", "description": "Input data accessible as global input variable..."},
"options": {"type": "object", "description": "Execution options..."}
}
}
}
Neither code nor script is schema-required: JSON Schema cannot express
"exactly one of", so the tool enforces it and rejects a call that supplies both
or neither.
Stored Scripts
Sending a long workflow inline costs its full token count on every run, retry,
and parameter tweak. A stored script is that workflow kept on the server —
a <name>.js / <name>.ts file in the scripts/ directory next to the active
configuration file — invoked by name:
{
"name": "code_execution",
"arguments": {
"script": "fetch-prs",
"input": {"owner": "acme", "repo": "api"}
}
}
Everything else is identical to an inline call: same sandbox, same
allowed_servers / max_tool_calls / timeout_ms handling, same quarantine and
permission enforcement, same activity and history records (which store the
executed source exactly as they do for inline code, plus the script name).
script changes only where the source text comes from.
1. Author a script
mkdir -p ~/.mcpproxy/scripts
cat > ~/.mcpproxy/scripts/fetch-prs.js <<'JS'
var rs = call_tools([1, 2, 3].map(function (n) {
return {server: "github", tool: "get_pull_request",
args: {owner: input.owner, repo: input.repo, pullNumber: n}};
}));
({titles: rs.map(function (r) { return r.ok ? JSON.parse(r.result.content[0].text).title : "ERR"; })});
JS
The directory is derived from the active config file, not from --data-dir:
with the default ~/.mcpproxy/mcp_config.json it is ~/.mcpproxy/scripts/, and
with --config /etc/mcpproxy/mcp_config.json it is /etc/mcpproxy/scripts/.
mcpproxy never creates the directory itself — an absent one simply means "no
scripts".
2. Run it
mcpproxy code scripts list
mcpproxy code exec --script fetch-prs --input='{"owner":"acme","repo":"api"}'
--script is mutually exclusive with --code and --file. In both daemon and
standalone mode the CLI sends the name; the daemon (or the in-process
handler) is the only thing that resolves it, so every surface agrees on what a
name means.
Naming and file rules
| Rule | Value |
|---|---|
| Name | 1-64 characters of A-Za-z0-9_-, case-sensitive |
| Path | never — a name with a separator, .., or a dot is rejected before any filesystem access |
| Extension | lowercase .js or .ts only (.JS, .mjs, .jsx are not scripts) |
| Language | derived from the extension; an explicit language that contradicts it is an error |
| Size | 1 byte to 256 KB — empty and oversized files are rejected |
| File type | regular files only; a symlink at the script path is rejected |
| Ambiguity | name.js and name.ts both present → the call fails naming both |
Files that break the name or extension rules are ignored by listings and unreachable by invocation — they are not scripts.
Confinement: the name is validated before the filesystem is touched, so a valid name cannot traverse out of the scripts directory by construction. On top of that, the file is opened with symlink-following disabled (atomically on Unix via
O_NOFOLLOW; a checked policy on Windows, where creating symlinks requires elevation). The scripts directory itself may be a symlink — it is operator-controlled.
Editing without a restart
Each invocation performs exactly one open and one bounded read; there is no
cache and no file watcher, so there is nothing to invalidate. Edit by atomic
replace — write a temporary file and rename it over the script — and the
next invocation runs the new content:
tmp=$(mktemp ~/.mcpproxy/scripts/.fetch-prs.XXXXXX)
cat > "$tmp" <<'JS'
({updated: true});
JS
mv "$tmp" ~/.mcpproxy/scripts/fetch-prs.js # atomic within the same filesystem
Adding or deleting a file is reflected on the next invocation or listing. Editing a script in place while it is being invoked is the one unsupported case: the run gets whatever the read returned (validated, but unspecified).
Discovering script names
mcpproxy code scripts list # human-readable, always names the directory it read
mcpproxy code scripts list -o json # {"dir": "...", "scripts": [{"name","paths","status"}]}
curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts
MCP clients do not get a listing tool — registrations are static, so an embedded list would go stale. Discovery is error-driven instead: invoking a name that does not exist returns an error listing the first 20 available names alphabetically plus the total, so an agent recovers the current name set from a single failed call.
Cannot execute stored script: stored script "fetch-pr" not found in
/Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage
No write path
Nothing in mcpproxy creates, edits, or deletes a stored script: no MCP tool, no REST endpoint, no CLI verb. The filesystem is the sole authoring interface, so sandboxed code that can run a stored workflow can never author one.
Common Patterns
Pattern 1: Sequential Tool Calls
// Wrap the body in an IIFE so early-exit `return`s are legal — a bare
// top-level `return` is a SyntaxError. The IIFE's value is the result.
(() => {
// Fetch user, then fetch their repos
const userRes = call_tool('github', 'get_user', {username: input.username});
if (!userRes.ok) {
return {error: userRes.error.message};
}
const reposRes = call_tool('github', 'list_repos', {user: input.username});
if (!reposRes.ok) {
return {error: reposRes.error.message};
}
return {
user: userRes.result,
repos: reposRes.result,
repo_count: reposRes.result.length
};
})();
Pattern 2: Conditional Logic
(() => {
// Try primary server, fallback to secondary
let result = call_tool('primary-db', 'query', {sql: input.query});
if (!result.ok) {
// Primary failed, try backup
result = call_tool('backup-db', 'query', {sql: input.query});
}
return result.ok ? result.result : {error: 'Both databases unavailable'};
})();
Pattern 3: Loop with Aggregation
(() => {
// Fetch details for multiple items
const results = [];
const errors = [];
for (const id of input.ids) {
const res = call_tool('api-server', 'get_item', {id});
if (res.ok) {
results.push(res.result);
} else {
errors.push({id, error: res.error});
}
}
return {
success: results,
failed: errors,
success_count: results.length,
error_count: errors.length
};
})();
Pattern 4: Data Transformation
(() => {
// Get repos and compute statistics
const reposRes = call_tool('github', 'list_repos', {user: input.username});
if (!reposRes.ok) return reposRes;
const repos = reposRes.result;
const totalStars = repos.reduce((sum, r) => sum + (r.stargazers_count ?? 0), 0);
const languages = {};
for (const repo of repos) {
const lang = repo.language ?? 'Unknown';
languages[lang] = (languages[lang] ?? 0) + 1;
}
return {
total_repos: repos.length,
total_stars: totalStars,
avg_stars: Math.round(totalStars / repos.length),
languages
};
})();
Pattern 5: Parallel Fan-out with call_tools
// Independent calls — no element depends on another's result
var prs = call_tools(
[1, 2, 3, 4, 5].map(function (n) {
return {server: 'github', tool: 'get_pull_request',
args: {owner: 'acme', repo: 'api', pullNumber: n}};
}),
{max_parallel: 5}
);
var titles = prs.map(function (r) {
if (!r.ok) { return 'ERR: ' + r.error.code; }
return JSON.parse(r.result.content[0].text).title;
});
({titles: titles});
call_tools(requests, options) dispatches up to max_parallel elements at a
time and returns one slot per request, in input order — so the batch takes about
as long as its slowest element instead of the sum of all of them. Rules:
requests: array of{server, tool, args?}, at most 100 elements;argsdefaults to{}.options.max_parallel: integer 1-32. Defaults tocode_execution_max_parallel(8). Unknown option keys are ignored.- Each slot is the same envelope
call_tool()returns, so a failing element never poisons its siblings. - Malformed arguments (not an array, bad element shape, sparse hole, bad
max_parallel, more than 100 elements) return a single{ok: false, error: {code: "INVALID_ARGS", ...}}envelope naming the first offending index, and nothing is dispatched. - Every element costs one unit of
max_tool_calls, checked in input order, and the whole batch runs inside the execution timeout. - Use it only for independent calls — chained steps still belong in a sequential pipeline (Pattern 1).
Per-server limits still apply. Concurrency limits are enforced inside the call path, never bypassed by batching. A server with
max_concurrent_requests: 1andqueue_size: 9serializes a 10-element batch; the same server with noqueue_sizesheds the overflow as per-slotqueue_fullerrors. Configurequeue_sizeheadroom (or lowermax_parallel) before fanning out against a limited server.
Error Handling
JavaScript Errors
// Syntax error - caught before execution
code_execution({code: "invalid javascript {"})
// Returns: {ok: false, error: {code: "SYNTAX_ERROR", message: "...", stack: "..."}}
// Runtime error - caught during execution
code_execution({code: "throw new Error('Something went wrong')"})
// Returns: {ok: false, error: {code: "RUNTIME_ERROR", message: "Something went wrong", stack: "..."}}
Tool Call Errors
(() => {
// Tool returns error - handled in JavaScript
var res = call_tool('github', 'get_user', {username: 'nonexistent-user-12345'});
if (!res.ok) {
return {error: 'User not found: ' + res.error.message};
}
return res.result;
})();
Timeout Errors
// Execution exceeds timeout (default: 2 minutes)
code_execution({
code: "while(true) {}", // Infinite loop
options: {timeout_ms: 1000}
})
// Returns: {ok: false, error: {code: "TIMEOUT", message: "JavaScript execution timed out"}}
Max Tool Calls Exceeded
// Exceeds max_tool_calls limit
code_execution({
code: "for (var i = 0; i < 100; i++) { call_tool('api', 'ping', {}); }",
options: {max_tool_calls: 10}
})
// Returns: {ok: false, error: {code: "MAX_TOOL_CALLS_EXCEEDED", message: "..."}}
TypeScript Support
You can write code execution scripts in TypeScript by setting the language parameter to "typescript". TypeScript types are automatically stripped before execution using esbuild, with near-zero transpilation overhead.
Supported TypeScript Features
- Type annotations:
const x: number = 42 - Interfaces:
interface User { name: string; age: number; } - Type aliases:
type StringOrNumber = string | number - Generics:
function identity<T>(arg: T): T { return arg; } - Enums:
enum Direction { Up = "UP", Down = "DOWN" } - Namespaces:
namespace MyLib { export const value = 42; } - Type assertions:
const x = value as string
TypeScript via MCP Tool
{
"name": "code_execution",
"arguments": {
"code": "interface User { name: string; }\nconst user: User = { name: input.username };\n({ greeting: 'Hello ' + user.name })",
"language": "typescript",
"input": {"username": "Alice"}
}
}
TypeScript via REST API
curl -X POST http://127.0.0.1:8080/api/v1/code/exec \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"code": "const x: number = 42; ({ result: x })",
"language": "typescript"
}'
TypeScript via CLI
mcpproxy code exec --language typescript \
--code="const x: number = 42; ({ result: x })"
Important Notes
- TypeScript support uses type-stripping only (no type checking or semantic validation)
- Valid JavaScript is also valid TypeScript, so you can always use
language: "typescript"even for plain JS - The transpiled output runs in the same ES2020+ goja sandbox with all existing capabilities
- Transpilation errors return the
TRANSPILE_ERRORerror code with line/column information
Best Practices
1. Keep Code Simple
- Use modern JavaScript syntax (arrow functions, const/let, template literals, destructuring are all supported)
- Avoid deeply nested logic
- Prefer explicit error handling over implicit failures
2. Handle Errors Gracefully
// Bad: Assumes success (and a bare top-level `return` is itself a SyntaxError)
(() => {
var user = call_tool('github', 'get_user', {username: input.username});
return user.result.name; // Crashes if user.ok is false
})();
// Good: Checks response, early-exit returns kept legal inside the IIFE
(() => {
var user = call_tool('github', 'get_user', {username: input.username});
if (!user.ok) {
return {error: user.error.message};
}
return {name: user.result.name};
})();
3. Set Appropriate Timeouts
// Quick operations: 30 seconds
{options: {timeout_ms: 30000}}
// Multiple tool calls: 2 minutes (default)
{options: {timeout_ms: 120000}}
// Heavy processing: 5 minutes
{options: {timeout_ms: 300000}}
4. Limit Tool Calls
// Protect against runaway loops
{
code: "for (var i = 0; i < input.items.length; i++) { ... }",
options: {max_tool_calls: 100}
}
5. Use Allowed Servers
// Restrict to specific servers for sensitive operations
{
code: "call_tool('production-db', 'delete', {id: input.id})",
options: {allowed_servers: ["production-db"]}
}
Next Steps
- Cookbook: See cookbook.md for 10 TypeScript orchestration recipes (batch, fan-out, retry, pagination, rate-limit) with token/latency benchmarks
- Examples: See examples.md for 10+ working code samples
- API Reference: See api-reference.md for complete schema documentation
- Troubleshooting: See troubleshooting.md for common issues and solutions
- CLI Usage: Run
mcpproxy code exec --helpfor command-line testing