docs/code_execution/overview.md
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.
✅ Use code_execution when:
❌ Don't use code_execution when:
call_tool directly)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: {...}})
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};
})();
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};
})();
┌─────────────────────────────────────────────┐
│ 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} │
└─────────────────────────────────────────────┘
code (or script), input, and options from the requestcode / script; for a script, read the stored file and derive its language (see Stored Scripts)input global and the call_tool() / call_tools() functions{ok: true, value: <result>} or {ok: false, error: {...}}The JavaScript execution environment is heavily sandboxed to prevent security issues:
❌ Not Available:
require() - No module loadingsetTimeout() / setInterval() - No timersfs modulehttp or fetchprocess.env✅ 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){
"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
}
}
Code execution respects existing MCPProxy security features:
call_tool()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
}
]
}
pkill mcpproxy
mcpproxy serve
# 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"}'
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.
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.
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".
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.
| 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.
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).
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
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.
// 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
};
})();
(() => {
// 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'};
})();
(() => {
// 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
};
})();
(() => {
// 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
};
})();
// 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; args
defaults to {}.options.max_parallel: integer 1-32. Defaults to code_execution_max_parallel
(8). Unknown option keys are ignored.call_tool() returns, so a failing element
never poisons its siblings.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.max_tool_calls, checked in input order, and
the whole batch runs inside the execution timeout.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.
// 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 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;
})();
// 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"}}
// 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: "..."}}
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.
const x: number = 42interface User { name: string; age: number; }type StringOrNumber = string | numberfunction identity<T>(arg: T): T { return arg; }enum Direction { Up = "UP", Down = "DOWN" }namespace MyLib { export const value = 42; }const x = value as string{
"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"}
}
}
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"
}'
mcpproxy code exec --language typescript \
--code="const x: number = 42; ({ result: x })"
language: "typescript" even for plain JSTRANSPILE_ERROR error code with line/column information// 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};
})();
// 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}}
// Protect against runaway loops
{
code: "for (var i = 0; i < input.items.length; i++) { ... }",
options: {max_tool_calls: 100}
}
// Restrict to specific servers for sensitive operations
{
code: "call_tool('production-db', 'delete', {id: input.id})",
options: {allowed_servers: ["production-db"]}
}
mcpproxy code exec --help for command-line testing