Skip to content

Code mode for agents

Serve REST tools to agents through one execute tool: the agent sends a TypeScript script, and Arion runs the whole workflow in a sandbox.

Request path

What you will build

ARION 1listener2jwt3mcp gateway4sandbox agent tools

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_tickets and support.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 codemode feature: 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.22 and 10.0.4.23 on port 8080 in the config. Edit the cluster endpoints to match yours.
  • An MCP client that can call tools. curl is enough to verify.

The architecture

  • The MCP gateway filter runs in code mode. Agents see one execute tool, 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 rbac then applies to every call a script makes, and hidden tools never show up in the catalog.
  • Keep limits tight. Size execution_timeout and max_host_calls to 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_executions are refused, not queued. Sandboxes for all listeners come from one pool under runtime.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 Toolkit resources; see tool discovery over xDS.

Next

Next

Take the configuration further

Start typing to search the site.

↑ ↓ to move Enter to open Esc to close