Connect

MCP

Model Context Protocol support runs in both directions. Ballast is itself an MCP server that agents like Claude Code can drive — list workflows, start runs, watch them, resolve approval gates — and any workflow tool node can call out to another MCP server. Both directions inherit RBAC, quotas, and the audit log.

There are two independent halves to Ballast's MCP support, and it helps to keep them straight:

  • Ballast as an MCP server — an external MCP client (Claude Code, Cursor, your own agent) points at POST /api/mcp and operates the workspace through six governed tools.
  • Ballast as an MCP client — a workflow tool node calls a tool on some other MCP server, either ad-hoc with the built-in mcp_call tool or through a registered kind: "mcp" tool.

Ballast as an MCP server

The platform exposes a streamable-HTTP MCP server at a single endpoint, POST /api/mcp. It speaks JSON-RPC 2.0 at protocol version 2025-06-18. Every message is a POST; the server replies with a plain JSON body (or, if the client negotiated it, a short text/event-stream containing the one response frame). A GET /api/mcp returns 405 with a hint to connect over POST — there is no long-lived SSE channel to open.

Authentication is the normal Ballast middleware: send an X-API-Key header or an Authorization: Bearer session token. There is no MCP-specific auth handshake — the credential is resolved on every POST, and each individual tools/call is additionally gated on the capability its REST equivalent requires, so a viewer key can list and read but never run or approve.

Connecting a client

From Claude Code, register the server once with claude mcp add and then talk to it in natural language:

Connect from Claude Code
$ claude mcp add --transport http ballast http://localhost:8000/api/mcp \
    --header "X-API-Key: aos_..."

# then, in a Claude Code session:
#   "list the Ballast workflows"
#   "run the Support Triage Crew workflow with ticket 'refund request' and wait for it"
#   "show me anything paused waiting for approval, then approve the oldest one"
The server currently advertises itself as serverInfo.name = "agent-os" (version 0.6.0) because the package rename to Ballast is still in flight. Clients key off the transport URL, not this string, so naming your local entry ballast is purely cosmetic and safe.

The JSON-RPC envelope

Every request is a JSON object with jsonrpc: "2.0", a method, an optional params object, and an id (any JSON value; omitted for notifications). A successful reply echoes the id and carries a result; a protocol-level failure carries an error with a numeric code and message instead.

Request and the two response shapes
// request
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }

// success response
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ ... ] } }

// error response
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method 'foo' not found" } }

No batching

The endpoint handles exactly one message per POST. A JSON array (a JSON-RPC batch) is rejected with -32600. Send requests one at a time.

Methods

Five methods are handled; anything else returns a not-found error.

FieldTypeDescription
initializerequestHandshake. Returns protocolVersion, the tools capability, and serverInfo. No capability required.
notifications/*notificationAny notifications/... message (e.g. notifications/initialized) is acknowledged with HTTP 202 and an empty body — no JSON-RPC result.
pingrequestLiveness check. Returns an empty result object {}.
tools/listrequestReturns the six tools, each with its JSON inputSchema.
tools/callrequestInvoke one tool by name with an arguments object. Capability-gated per tool; see below.

initialize

The first call a client makes. Ballast ignores the client's requested protocol version and always answers with its own, 2025-06-18, plus an empty tools capability object (which signals only that tools exist — the client should follow up with tools/list).

initialize — request then response
// request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {},
    "clientInfo": { "name": "my-agent", "version": "1.0.0" }
  }
}

// response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "tools": {} },
    "serverInfo": { "name": "agent-os", "version": "0.6.0" }
  }
}

A well-behaved client then sends a notifications/initialized notification. Ballast replies 202 Accepted with no body — this is a one-way message, so it has no id and expects no result. The session is otherwise stateless: the server holds no per-connection state, so you can also skip straight to tools/list or tools/call if your client does not run the full handshake.

The six tools

tools/list returns all six with their JSON input schemas. Each one maps to a REST endpoint and enforces exactly the capability that endpoint requires:

FieldTypeDescription
list_workflows(no arguments)Every workflow in the workspace with status, version, graph, and aggregate run stats.
get_workflowworkflowOne workflow by id or exact name, including its full node/edge graph. Errors if nothing matches.
run_workflowworkflow, input?, wait_seconds?Validate the graph, start a run, and optionally poll up to wait_seconds (capped at 60) for it to settle at success, failed, or paused.
get_runrun_idA run with step-level timeline, cost, output, and any pending approval gate.
list_pending_approvals(no arguments)Runs currently paused at a human gate, each with run_id, workflow_name, the gate prompt, and created_at.
approve_gaterun_id, approved, input?Resolve a paused gate. approved=true merges optional input into run state and resumes; approved=false fails the run.

tools/call and result shapes

A call names the tool and passes an arguments object. On success the result carries two representations of the same data: a human-readable content array (a single text item whose text is the JSON-serialized payload, truncated at 20,000 characters) and a machine-readable structuredContent object. List-returning tools wrap their array under structuredContent.items; object-returning tools place the object directly. A isError: false flag marks a clean call.

tools/call — run a workflow and wait
// request
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "run_workflow",
    "arguments": {
      "workflow": "Support Triage Crew",
      "input": { "ticket": "I was double charged" },
      "wait_seconds": 30
    }
  }
}

// response — the run paused at a human gate within the wait window
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "isError": false,
    "content": [
      { "type": "text", "text": "{ ...the run object, JSON-serialized... }" }
    ],
    "structuredContent": {
      "id": "af772b3602ef",
      "workflow_id": "9bc62f209c52",
      "status": "paused",
      "cost_usd": 0.0038,
      "pending_gate": { "node_id": "review", "prompt": "Approve the drafted reply?" },
      "steps": [ ... ]
    }
  }
}

run_workflowdoes real work before it returns: it resolves the workflow by id or name, checks the monthly run quota, re-validates the graph against the registered tool set (a broken graph surfaces as an error result rather than starting a run), creates the run attributed to the calling credential's member, writes an audit entry tagged via: "mcp", and starts execution. If wait_seconds is supplied it then polls every ~0.4s until the run reaches success, failed, or paused, or the (max-60s) window elapses — whichever comes first — and returns the run in whatever state it is in. Omit wait_seconds to return immediately with a pending/running run and poll later with get_run.

Capability gating and errors

Before dispatching a tools/call, the server looks up the credential's permissions and checks the capability the tool needs. A credential that lacks it does not get a JSON-RPC error — it gets a normal result with isError: true and an explanatory message, which is what MCP clients render back to the model:

Denied by capability (still a result, not a protocol error)
{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "This credential lacks the 'run_workflows' permission" }
    ]
  }
}

Exceptions raised inside a tool (workflow not found, quota exceeded, invalid graph, a rejected approval on a non-paused run) are reported the same way — isError: true with the message text — so the calling agent can read and react to them. Genuinely malformed requests, by contrast, come back as JSON-RPC error objects:

FieldTypeDescription
-32700Parse errorThe POST body was not valid JSON.
-32600Invalid requestThe body was a JSON array — batch requests are not supported.
-32601Method not foundThe method is not one of initialize / ping / tools/list / tools/call.
-32602Invalid paramstools/call named a tool that does not exist.

Human gates still hold

An external agent driving Ballast gets no shortcut around governance. If a workflow pauses at a gate, the MCP caller needs the approve_gatescapability to resolve it — a viewer or run-only key can watch the pending approval but cannot clear it. And every action lands in the audit log attributed to the key's member, so who ran what through MCP is as legible as who clicked it in the console.

Raw JSON-RPC by curl

Nothing about the transport is magic — it is HTTP and JSON. You can drive the whole thing with curl. Send the accept header the streamable-HTTP spec expects so the server may answer with either JSON or an event stream:

Handshake, list, and call by hand
# 1) initialize
$ curl -s -X POST http://localhost:8000/api/mcp \
    -H "X-API-Key: aos_..." \
    -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":"0"}}}'

# 2) list tools
$ curl -s -X POST http://localhost:8000/api/mcp \
    -H "X-API-Key: aos_..." -H "content-type: application/json" \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# 3) call run_workflow and block up to 30s
$ curl -s -X POST http://localhost:8000/api/mcp \
    -H "X-API-Key: aos_..." -H "content-type: application/json" \
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_workflow","arguments":{"workflow":"Support Triage Crew","input":{"ticket":"refund request"},"wait_seconds":30}}}'
In AUTH_MODE=open local development the X-API-Key header is optional — unauthenticated MCP calls act as the workspace owner and pass every capability check. Set a real AUTH_MODE before exposing the endpoint to anything you do not fully trust.

Calling MCP servers from workflows

The other direction: a tool node in one of your workflows calls a tool on some external MCP server — a docs search server, a database MCP bridge, another Ballast workspace. There are two ways to wire it, depending on whether the server is a one-off or a team fixture. Both share the same underlying MCP client.

Ad-hoc: the mcp_call built-in tool

mcp_call is a built-in tool in the engine registry (alongside http_request, slack_message, send_email, and delay). Point a tool node at it and give it the server URL, the target tool, and its arguments inline:

Tool node config
{
  "tool_name": "mcp_call",
  "params": {
    "server_url": "https://mcp.example.com/mcp",
    "tool": "search_docs",
    "arguments": { "query": "{topic}" },
    "headers": { "Authorization": "Bearer ..." }
  },
  "output_key": "docs"
}
FieldTypeDescription
server_urlstring (required)The streamable-HTTP MCP endpoint to call. Subject to the egress guard.
toolstring (required)The name of the tool to invoke on that server.
argumentsobjectThe arguments object passed straight through as the tool's arguments.
headersobjectExtra request headers, e.g. bearer auth for the remote server.

Params are template-rendered against run state first ({topic} above is filled from an earlier step), so the arguments the remote tool sees are concrete values. The result is stored in state under output_key as { "result": ... }— the engine prefers the server's structuredContent when present, otherwise it takes the joined text content and JSON-parses it when it can, falling back to the raw string. A tool result flagged isErrorraises, which fails the node — so the engine's normal retry machinery (per the node's retry_count) applies to flaky MCP calls just as it does to any other tool.

The client handshake

Under the hood, each mcp_call opens a fresh HTTP client and runs the full MCP handshake before the actual call, over a single connection:

What the engine sends, in order
// 1) initialize  (id: 1)
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
  "params": { "protocolVersion": "2025-06-18", "capabilities": {},
              "clientInfo": { "name": "agent-os", "version": "0.6.0" } } }

// 2) notifications/initialized  (a notification — no id, no response expected)
{ "jsonrpc": "2.0", "method": "notifications/initialized" }

// 3) tools/call  (id: 2)
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
  "params": { "name": "search_docs", "arguments": { "query": "durable agents" } } }
  • The engine sends accept: application/json, text/event-stream on every request and parses either shape — a JSON body, or an SSE stream from which it reads the first data: frame that carries an id. A 202 or empty body (the expected reply to the notification) is treated as an empty result.
  • Session-id handling. If the server returns an mcp-session-id response header (typically on the initialize reply), the engine captures it and echoes it back as the Mcp-Session-Id request header on the notifications/initialized and tools/call that follow, keeping all three on one server session.
  • If initialize or the call itself returns a JSON-RPC error, or the tool result is isError, the engine raises with the server's message — which fails the node.

Registered: kind "mcp" tools

For servers the whole team uses, register the tool once and reference it from any node as custom:<name>. The endpoint, the target tool, the headers, and the input schema live in the tool registry instead of being copied into every node config:

POST/api/tools

Register an MCP server tool as custom:<name>. Requires create_edit_workflows. kind:mcp forwards node params as the MCP tool's arguments after schema validation.

Register an MCP-backed tool
$ curl -X POST http://localhost:8000/api/tools \
    -H "X-API-Key: aos_..." -H "content-type: application/json" \
    -d '{
      "name": "search-docs",
      "kind": "mcp",
      "endpoint_url": "https://mcp.example.com/mcp",
      "mcp_tool": "search_docs",
      "headers": { "Authorization": "Bearer ..." },
      "input_schema": {
        "type": "object",
        "properties": { "query": { "type": "string" } },
        "required": ["query"]
      }
    }'

At run time the engine validates the node's params against input_schema (required keys and primitive types), then runs the very same handshake and tools/call as mcp_call, using mcp_toolas the target (falling back to the registration's name if mcp_tool is unset) and forwarding the validated params as the arguments. The difference from mcp_call is purely where the wiring lives: in the registry, versioned and reused, rather than inline in each node.

Egress in production

HTTP_TOOL_BLOCK_PRIVATE=1 applies to MCP calls exactly as it does to http_request: before any connection, the target host is resolved and refused if it maps to a private, loopback, link-local, or reserved address. That holds for both mcp_call and kind: "mcp" tools, so a workflow cannot use an MCP node to probe your internal network.