requires: codemode
This guide builds an MCP gateway in code mode for a support workflow. Code mode plays to the model's strength — writing code: the agent resolves a request (find the customer, check their history and plan, open a ticket at the right priority) by writing one TypeScript script instead of making four tool calls over four model turns. Use it when agents run multi-step workflows over your APIs and you want fewer round trips and a cleaner context, with the same tools and the same guardrails.
Outcome
- A listener on port 8080 serving MCP at
POST /mcp, with a single tool,execute. - Four REST-backed tools in three namespaces:
crm.find_customer,billing.current_plan,support.recent_ticketsandsupport.create_ticket. - A toolkit,
resolve_support_request, that hands the model the instructions and all four schemas in one call. - Scripts limited to 20 seconds and 50 tool, catalog and log calls each.
Prerequisites
- An Arion binary built with the
codemodefeature:cargo build --release -p arion-proxy --features codemode. A build without it rejects this config at startup. - Three REST services, at
10.0.4.21,10.0.4.22and10.0.4.23on port 8080 in the config. Edit the cluster endpoints to match yours. - An MCP client that can call tools.
curlis enough to verify.
The architecture
- The MCP gateway filter runs in code mode. Agents see one
executetool, and each call runs the agent's script in a fresh WebAssembly sandbox inside Arion. - The script calls tools by qualified name. Each call goes through the gateway's tool registry, with schema validation, authorization and limits, then out to the tool's cluster.
- When the script returns, Arion sends back one result: the script's value, its log lines and a record of every tool call.
The complete config
# arion.yaml — an MCP gateway in code mode. Needs a binary built with the codemode feature.
logging:
log_level: info
envoy_bootstrap:
static_resources:
listeners:
- name: http_8080
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- name: http_8080_chain
filters:
- name: http_gateway
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
http_filters:
- name: arion.filters.http.mcp
typed_config:
"@type": type.googleapis.com/arion.extensions.filters.http.mcp.mcp_gateway.v3.McpGateway
server_info:
name: support-tools
version: 1.0.0
code_mode:
execution_timeout: 20s
max_host_calls: 50
toolkits:
- name: resolve_support_request
description: Find a customer, check their plan and open a ticket at the right priority.
instructions: >-
Find the customer by email with crm.find_customer first. Then fetch
support.recent_tickets and billing.current_plan in parallel with the
customer's id. Create exactly one ticket with support.create_ticket:
priority high for the enterprise tier, normal otherwise. Return the
ticket id and how many recent tickets the customer had, not the raw
responses.
tools:
- { namespace: crm, name: find_customer }
- { namespace: support, name: recent_tickets }
- { namespace: billing, name: current_plan }
- { namespace: support, name: create_ticket }
tools:
- namespace: crm
name: find_customer
description: Find a customer by email address.
input_schema:
inline_string: |
{"type": "object", "properties": {"email": {"type": "string"}}, "required": ["email"]}
output_schema:
inline_string: |
{"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}}, "required": ["id"]}
rest_backend:
cluster: crm_api
method: GET
path: /v1/customers
query_params:
- name: email
source: email
- namespace: billing
name: current_plan
description: Get a customer's current billing plan and tier.
input_schema:
inline_string: |
{"type": "object", "properties": {"customer_id": {"type": "string"}}, "required": ["customer_id"]}
output_schema:
inline_string: |
{"type": "object", "properties": {"tier": {"type": "string"}, "renews_at": {"type": "string"}}, "required": ["tier"]}
rest_backend:
cluster: billing_api
method: GET
path: /v1/customers/{{customer_id | encode_path_segment}}/plan
- namespace: support
name: recent_tickets
description: List a customer's most recent support tickets.
input_schema:
inline_string: |
{"type": "object", "properties": {"customer_id": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["customer_id"]}
output_schema:
inline_string: |
{"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object"}}}, "required": ["items"]}
rest_backend:
cluster: support_api
method: GET
path: /v1/customers/{{customer_id | encode_path_segment}}/tickets
query_params:
- name: limit
source: limit
- namespace: support
name: create_ticket
description: Open a support ticket for a customer.
input_schema:
inline_string: |
{"type": "object", "properties": {"ticket": {"type": "object", "properties": {"customer_id": {"type": "string"}, "priority": {"type": "string", "enum": ["low", "normal", "high"]}, "summary": {"type": "string"}}, "required": ["customer_id", "priority", "summary"]}}, "required": ["ticket"]}
output_schema:
inline_string: |
{"type": "object", "properties": {"id": {"type": "string"}, "status": {"type": "string"}}, "required": ["id"]}
rest_backend:
cluster: support_api
method: POST
path: /v1/tickets
body_json: ticket
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
route_config:
name: http_8080_routes
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: /
route:
cluster: crm_api
clusters:
- name: crm_api
connect_timeout: 5s
type: STATIC
load_assignment:
cluster_name: crm_api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 10.0.4.21, port_value: 8080 }
- name: billing_api
connect_timeout: 5s
type: STATIC
load_assignment:
cluster_name: billing_api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 10.0.4.22, port_value: 8080 }
- name: support_api
connect_timeout: 5s
type: STATIC
load_assignment:
cluster_name: support_api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 10.0.4.23, port_value: 8080 }Walkthrough
1. The gateway in code mode
code_mode takes the place of tools_mode: {} and makes execute the gateway's only tool. Its limits are per listener: execution_timeout: 20s bounds each run's wall-clock time, and max_host_calls: 50 bounds the tool calls, catalog calls and log lines a single script may make. Everything else keeps its default, including 32 concurrent runs, 8 parallel calls per run and a 1 MiB result. See code mode.
2. Tools in namespaces
Every tool has a namespace and a name, and scripts call it by the qualified name, such as crm.find_customer. The namespaces also organize discovery: the execute description lists them, and catalog.tools.list({ namespace }) pages through one. See tools and REST backends.
3. Output schemas and safe paths
Each tool declares an output_schema, so its JSON response reaches the script already parsed. That is what makes customer.data.id work; without a schema the script would get the body as text. The two path templates use encode_path_segment, so a customer id the model produced cannot escape its path segment. create_ticket sends its ticket argument as the JSON body with body_json.
4. A toolkit
resolve_support_request names the four tools and says, in plain words, how to combine them. The model sees the toolkit in the execute description, and one catalog.toolkits.get call returns the instructions with every member tool's input and output schemas. A caller sees a toolkit only if it may use all of its tools.
5. What the agent sends
With the toolkit in hand, a model writes a script like this and sends it as the code argument of execute:
const customer = await tools["crm.find_customer"]({ email: "ada@example.com" });
if (!customer.ok) return { error: "unknown customer" };
const id = customer.data.id;
const [history, plan] = await Promise.all([
tools["support.recent_tickets"]({ customer_id: id, limit: 5 }),
tools["billing.current_plan"]({ customer_id: id }),
]);
const ticket = await tools["support.create_ticket"]({
ticket: {
customer_id: id,
priority: plan.ok && plan.data.tier === "enterprise" ? "high" : "normal",
summary: "Refund not received",
},
});
if (!ticket.ok) return { error: ticket.error.code };
return { ticket: ticket.data.id, seen: history.ok ? history.data.items.length : 0 };
The two lookups run in parallel. Every call resolves to { ok, data } or { ok: false, error }, so the script branches instead of catching exceptions. Only the small object on the last line goes back to the model.
6. What comes back
The execute result's structuredContent holds status, the script's result, any logs, and toolCalls, which lists the four calls with their outcome and duration. A failed script comes back as status: "error" with a diagnostic, such as a parse error with its line number or a limit that was hit, and without gateway internals. The shapes are listed in the code mode SDK reference.
Run and verify
Save the config as arion.yaml and start Arion:
arion --config arion.yaml
Initialize a session and keep the mcp-session-id from the response headers:
MCP_ENDPOINT=http://127.0.0.1:8080/mcp
curl -i -s "$MCP_ENDPOINT" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'
SESSION_ID=<session-id-from-initialize>
The initialize result includes instructions pointing the client at execute. List the tools, then run a script that fetches the toolkit:
curl -s "$MCP_ENDPOINT" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
curl -s "$MCP_ENDPOINT" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute","arguments":{"code":"return await catalog.toolkits.get({ name: \"resolve_support_request\" })"}}}'
tools/list returns exactly one tool, execute, whose description names the resolve_support_request toolkit and the billing, crm and support namespaces. The tools/call result has "status": "completed". Its result.data.content starts with the toolkit's instructions and includes an Invocation: tools["crm.find_customer"](arguments) line for each tool. Send the script from step 5 the same way to run the whole workflow against your services.
Production notes
- Authenticate first. Put the JWT filter before the gateway, as in the MCP gateway guide. Per-tool
rbacthen applies to every call a script makes, and hidden tools never show up in the catalog. - Keep limits tight. Size
execution_timeoutandmax_host_callsto the workflows you expect, not the defaults. A runaway script then fails fast, and each run has a fixed 5-second compute budget on top. - Plan capacity. Runs beyond
max_concurrent_executionsare refused, not queued. Sandboxes for all listeners come from one pool underruntime.codemode, which caps them process-wide. - Keep a tools-mode listener for clients that need the classic tool list. A gateway is one mode or the other, never both.
- Change toolkits without a restart. Deliver them as TDS
Toolkitresources; see tool discovery over xDS.