Skip to content

Call external MCP tools from a flow

A multi-node headless run can call tools on an external MCP server — any third-party Model Context Protocol server speaking Streamable HTTP. You register the server once, a flow node lists the tool in its data.tools, and the platform proxies the call through the hadrontool-mcp conduit — a stateless capability tool that speaks MCP so the core doesn't have to.

The boundary is the usual one: hadron-server owns the registry, the encrypted auth headers, and all authorization; the conduit only speaks the protocol. Registration grants nothing on its own — every call still walks the run's policy chain and spends from its action budget.

GraphQL today; CLI coming

There are no hadron mcp … CLI commands yet (hadron-cli#220). Register and browse through GraphQL via the CLI's raw hadron api escape hatch, shown below. This page will gain the CLI form once it ships.

Prerequisites

  • Org ADMIN or OWNER — the registry mutations are admin-gated.
  • The conduit enabled on your server — an operator sets MCP_TOOL_URL (and MCP_TOOL_TOKEN) so the core can reach hadrontool-mcp. Unset ⇒ any external-MCP operation fails MCP_TOOL_NOT_CONFIGURED.
  • A flow to call the tool from — see Build a branching automation and Multi-node run flows.
  • The external server's Streamable-HTTP URL and, if it needs auth, a static header (e.g. a bearer token).

Step 1: Register the server

createMcpServer records the server in your org's registry. The slug becomes the tool-name segment (mcp__<slug>__<tool>) and is immutable once set — lowercase [a-z0-9-], starting alphanumeric, max 24 chars, no underscores (the __ separator depends on that).

# Keep the secret out of shell history — read it from the environment.
hadron api -F orgRef=acme.com \
  -F "headers={\"Authorization\":\"Bearer $EVERART_KEY\"}" '
  mutation ($orgRef: ID!, $headers: JSON) {
    createMcpServer(
      orgRef: $orgRef
      slug: "everart"
      name: "EverArt image tools"
      url: "https://mcp.everart.example/mcp"
      headers: $headers
      toolAllowlist: ["generate_image"]
    ) { id slug hasHeaders enabled }
  }'

Notes on the arguments:

  • headers — a JSON object of static request headers. It is encrypted at rest and write-only: no query ever returns the values, only hasHeaders: true. Send {} or omit for an unauthenticated server.
  • toolAllowlist — restrict which of the server's tools are callable. Empty (or omitted) means every tool the server advertises is allowed; list names to narrow it.
  • enabled — defaults on; a disabled server fails tool resolution loud (see below).

Save the returned id — you reference the server by its id or URN (ref) from here on.

Step 2: Browse the tools it advertises

mcpServerTools does a live tools/list against the server and returns exactly what a run may call — the allowlist, name grammar, and length caps are already applied, so what you see is what runs. Copy the runToolName you want.

hadron api -F ref=<server-id> '
  query ($ref: ID!) {
    mcpServerTools(ref: $ref) { name runToolName description }
  }'
{ "data": { "mcpServerTools": [
  { "name": "generate_image",
    "runToolName": "mcp__everart__generate_image",
    "description": "Generate an image from a text prompt." }
] } }

A disabled server answers MCP_SERVER_DISABLED here rather than listing tools you couldn't call; a server the conduit can't reach surfaces an MCP_TOOL_* error (see error codes).

Step 3: Grant the tool, then add it to a flow node

Two independent things must both be true — registration alone grants nothing.

Grant it in the run's policy chain. Each call is authorized as the action tool.mcp__<slug>__<tool>, so the trigger's policy allow-list must include it (as with any run action — see Multi-node run flows):

hadron schedule create --app acme.com:ops --name nightly-render \
  --cron '0 7 * * *' --entry acme.com::ops::flow:render \
  --policy '{"allow":["tool.mcp__everart__generate_image"]}'

Without the grant, the call fails ACTIVATION_DENIED mid-hop.

List it in the node's data.tools. Put the runToolName on the flow node that should call it:

hadron node update acme.com::ops::flow:render \
  --data-merge '{"tools":["mcp__everart__generate_image"]}'

At load time the kernel validates the entry against your registry — unknown slug, disabled server, or a tool outside the allowlist fails the node NODE_LOAD_FAILED before any model call. (Remember a node that declares tools can't also carry an extractionSpec.)

Step 4: Run the flow

Trigger the run as usual — the tool node enters a bounded tool loop, and each MCP call is proxied through the conduit to the external server:

hadron run trigger --app acme.com:ops --entry acme.com::ops::flow:render --wait

What to expect:

  • Every MCP call costs 1 action from the run budget — there are no cost-0 reads, because an external side effect is unknowable and a retried flow must never repeat one.
  • A tool-level error is a result, not a run failure. If the external tool returns MCP isError: true, that comes back to the model as a result it can react to — the run does not fail. Only transport or protocol failures raise a typed MCP_TOOL_* error and end the hop.
  • Results are flattened to text. Non-text content blocks are surfaced as an [unsupported … omitted] marker; the total is capped so an upstream can't flood the hop's context.

Managing a registered server

# Update mutable fields (slug can't change). `headers` REPLACES the stored
# object; use clearHeaders: true to remove it (one or the other, not both).
hadron api -F ref=<server-id> '
  mutation ($ref: ID!) {
    updateMcpServer(ref: $ref, toolAllowlist: ["generate_image","describe_image"], enabled: true) {
      slug toolAllowlist enabled hasHeaders
    }
  }'

# Hard-delete it — runs that reference its tools fail loud afterward.
hadron api -F ref=<server-id> 'mutation ($ref: ID!) { deleteMcpServer(ref: $ref) }'

To take a server out of service without deleting it, set enabled: false — tool resolution then fails loud (MCP_SERVER_DISABLED) instead of calling it.

v1 limits

  • Streamable-HTTP servers only — no stdio, no legacy HTTP+SSE.
  • Static-header auth only — a registered (encrypted) header such as Authorization. MCP OAuth 2.1 is not supported in v1.
  • One session per request — the conduit does initialize → call → close each time; no session reuse, subscriptions, or server-initiated features.
  • Text results only — non-text blocks are omitted; responses are bounded (100 blocks, 200k chars per text block, capped again core-side).
  • No private-network upstreams — hosts resolving to private, loopback, or link-local addresses are refused (private_network_blocked).

Error codes

Surfaced by mcpServerTools and by a run's MCP calls:

Code Meaning
MCP_TOOL_NOT_CONFIGURED The server has no MCP_TOOL_URL set — the conduit isn't wired up.
MCP_TOOL_UNREACHABLE The conduit itself couldn't be reached.
MCP_TOOL_ERROR The conduit reached the external server but the call failed; mcpErrorCode says how.
MCP_SERVER_DISABLED The registered server is enabled: false.

MCP_TOOL_ERROR carries a passthrough mcpErrorCode naming the upstream failure: upstream_unreachable, upstream_timeout, upstream_protocol_error, private_network_blocked, or validation_error.

Gotchas

  • The slug is permanent. Flow nodes reference it in data.tools; pick it deliberately (updateMcpServer won't change it).
  • Registration ≠ permission. You still have to allow tool.mcp__<slug>__<tool> in the run's policy chain.
  • what you see is what runs. Browse with mcpServerTools, not the server's own docs — the allowlist and caps are already applied there.
  • Headers are write-only. You can't read a stored token back; to rotate it, send a new headers object (it replaces the old).
  • A vanished upstream tool fails loud. If a tool the flow declares is no longer advertised at run time, the hop fails rather than silently skipping it.