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(andMCP_TOOL_TOKEN) so the core can reachhadrontool-mcp. Unset ⇒ any external-MCP operation failsMCP_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, onlyhasHeaders: 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:
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 typedMCP_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 → closeeach 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 (updateMcpServerwon'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 withmcpServerTools, 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
headersobject (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.
Related¶
- Multi-node run flows — the
data.toolsfield, action budget, and failure codes these tools plug into. - Build a branching automation — a worked flow to add an MCP tool call to.
- Capability tools — why the conduit is a separate stateless service, and the boundary it keeps.
- GraphQL API — the generated
createMcpServer/mcpServerTools/mcpServersurface.