- Posts
- Connect an AI agent to Ironflow with a scoped key
Connect an AI agent to Ironflow with a scoped key
Your agent needs an order summary. The same engine also has a function that cancels orders. Giving the agent access to the engine raises a second question: which calls will the engine accept?
Ironflow v0.43.1 adds remote MCP. A client connects to the engine over HTTP with its own API key. Tool calls into the engine’s API forward that same key, so RBAC and, when enabled, CEL deny policies decide whether the call is allowed. The engine supplies no privileged key on the client’s behalf.
This walkthrough builds two small Node functions and a local MCP client. First, a viewer key lists the functions but cannot invoke them. Then a developer key, narrowed by two deny policies, invokes the summary function and gets rejected when it tries the cancellation function or an event emission. No model API or paid account is needed to test this authorization path.
Start an authenticated local engine
Section titled “Start an authenticated local engine”Use Ironflow v0.43.1, Node.js 24, and fish. The installation guide has the CLI downloads. Use a new directory and keep port 9123 free. These commands use local SQLite and an ephemeral embedded NATS port.
mkdir remote-mcp-democd remote-mcp-demoironflow versionset -e IRONFLOW_DATABASE_URLset -e NATS_URLset -e NATS_CREDS_FILEset -e NATS_STORE_DIRset -e NATS_STREAM_REPLICASset -e IRONFLOW_BOOTSTRAP_ADMIN_KEYset -e IRONFLOW_BOOTSTRAP_ADMIN_PASSWORDset -gx IRONFLOW_MCP_REMOTE trueset -gx IRONFLOW_POLICY_ENFORCEMENT trueironflow serve --host 127.0.0.1 --port 9123 --nats-port -1Keep this terminal running. Remote MCP and CEL enforcement are both off by
default. This uses the flags-only configuration path.
Do not add --dev: remote MCP refuses to start with authentication bypassed.
Give the engine two functions
Section titled “Give the engine two functions”Open a second fish terminal in remote-mcp-demo. Read the first-boot admin key
from its local file, then install the matching published SDK:
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')set -e IRONFLOW_ENVnpm init -ynpm install --save-exact @ironflow/node@0.43.1The admin key belongs to the setup terminal and worker, not the MCP client.
Save this as worker.mjs:
import { createFunction, createWorker } from "@ironflow/node";
const summary = createFunction( { id: "order-summary", triggers: [] }, async ({ step }) => step.run("summarize", async () => ({ orders: 3, totalCents: 6000, })),);
const cancel = createFunction( { id: "cancel-orders", triggers: [] }, async ({ step }) => step.run("cancel", async () => ({ cancelled: 3 })),);
await createWorker({ functions: [summary, cancel] }).start();Both functions return fixed demonstration data. The cancellation function does not cancel real orders. Empty trigger lists mean these functions are invoked directly, rather than through an event subscription.
node worker.mjsWait until the worker logs Connected to server at http://127.0.0.1:9123
before continuing. The SDK logs each function registration before that line.
Create separate client keys
Section titled “Create separate client keys”Open a third fish terminal in the same directory. Set up its admin credential and create two expiring keys. The redirected files contain secrets, so the commands set an owner-only file creation mask first.
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')set -e IRONFLOW_ENVumask 077ironflow role listironflow apikey create mcp-reader --role role_viewer --env env_default --expires 1h > reader-key.txtironflow apikey create mcp-summary --role role_developer --env env_default --expires 1h > agent-key.txtset reader_key_id (node -p 'require("node:fs").readFileSync("reader-key.txt", "utf8").match(/id: (ak_[^)]+)/)[1]')set agent_key_id (node -p 'require("node:fs").readFileSync("agent-key.txt", "utf8").match(/id: (ak_[^)]+)/)[1]')The role IDs above are the built-in roles in this fresh instance. The viewer can read functions and runs. The developer can invoke functions and has other permissions, which we will narrow before using its key.
Restrict direct invocation to the summary function
Section titled “Restrict direct invocation to the summary function”RBAC grants functions:invoke by action. A CEL deny policy can then reject
that action for other function resources. It cannot grant a permission the role
lacks.
Direct invocation has two checks: the route check has an empty resource; the
handler check has the function’s Ironflow Resource Name, or IRN. Denying every
resource except the summary IRN would also deny the empty route resource.
Guard the expression with request.resource != "" so the handler can make
the function-specific decision.
set summary_irn irn:ironflow:org_default:proj_default_default:function:env_default:order-summaryset cancel_irn irn:ironflow:org_default:proj_default_default:function:env_default:cancel-ordersset invoke_condition 'subject.api_key_id == "'$agent_key_id'" && request.resource != "" && request.resource != "'$summary_irn'"'ironflow policy test --condition "$invoke_condition" --request '{"resource":""}' --subject '{"api_key_id":"'$agent_key_id'"}'ironflow policy test --condition "$invoke_condition" --request '{"resource":"'$summary_irn'"}' --subject '{"api_key_id":"'$agent_key_id'"}'ironflow policy test --condition "$invoke_condition" --request '{"resource":"'$cancel_irn'"}' --subject '{"api_key_id":"'$agent_key_id'"}'The three Matched values should be false, false, and true. These are
expression tests: true means this deny condition matches, not that the whole
request is authorized. Now save the policy and a second policy that denies
every action outside the client’s short list:
ironflow policy create --name mcp-summary-function --effect deny --actions functions:invoke --resources '*' --condition "$invoke_condition"set action_condition 'subject.api_key_id == "'$agent_key_id'" && !(request.action in ["mcp:use", "functions:list", "functions:read", "functions:invoke", "runs:read"])'ironflow policy test --condition "$action_condition" --request '{"action":"events:emit"}' --subject '{"api_key_id":"'$agent_key_id'"}'ironflow policy create --name mcp-summary-actions --effect deny --actions '*' --resources '*' --condition "$action_condition"The event-emission expression test should print Matched: true. Both policies
match this key’s ID, so they do not restrict the worker’s admin key.
Exercise the actual MCP connection
Section titled “Exercise the actual MCP connection”Save this as mcp.mjs. It initializes a streamable HTTP MCP connection, sends
one tool call, and prints the tool’s text content. It exits with code 1 when
the tool reports an error, even if the HTTP response itself succeeded.
const url = `${process.env.IRONFLOW_SERVER_URL}/api/v1/mcp`;const key = process.env.MCP_API_KEY;if (!key) throw new Error("Set MCP_API_KEY to a client key");let protocolVersion = "2025-11-25";
async function send(message) { const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": protocolVersion, }, body: JSON.stringify({ jsonrpc: "2.0", ...message }), signal: AbortSignal.timeout(45000), }); if (!response.ok) throw new Error(`MCP HTTP ${response.status}`); if (response.status === 202) return; const reply = await response.json(); if (reply.error) throw new Error(JSON.stringify(reply.error)); return reply.result;}
const initialized = await send({ id: 1, method: "initialize", params: { protocolVersion, capabilities: {}, clientInfo: { name: "scoped-key-demo", version: "1.0.0" }, },});protocolVersion = initialized.protocolVersion;await send({ method: "notifications/initialized" });const result = await send({ id: 2, method: "tools/call", params: { name: process.argv[2], arguments: JSON.parse(process.argv[3] ?? "{}") },});console.log(result.content.filter(item => item.type === "text").map(item => item.text).join("\n"));if (result.isError) process.exitCode = 1;This client targets Ironflow’s JSON responses. It is a small verification client, not a general MCP SDK or a hosted provider’s configuration file.
Start with the viewer:
set -gx MCP_API_KEY (node -p 'require("node:fs").readFileSync("reader-key.txt", "utf8").match(/^Key: (.+)$/m)[1]')node mcp.mjs ironflow_list_functions '{}'node mcp.mjs ironflow_invoke_function '{"function_id":"order-summary"}'echo $statusThe list includes both functions. Invocation prints a permission_denied
error and the exit code is 1. Discovering a tool or a function does not grant
permission to invoke it.
Switch only the MCP credential to the narrowed developer key:
set -gx MCP_API_KEY (node -p 'require("node:fs").readFileSync("agent-key.txt", "utf8").match(/^Key: (.+)$/m)[1]')node mcp.mjs ironflow_invoke_function '{"function_id":"order-summary"}' > allowed-run.jsonset run_id (node -p 'JSON.parse(require("node:fs").readFileSync("allowed-run.json", "utf8")).run_id')node mcp.mjs ironflow_wait_for_run '{"run_id":"'$run_id'","timeout_seconds":30}'Invocation returns a run_id, not the function’s final output. The wait tool
retrieves the run. For this worker, expect status: "completed" and an output
of {"orders":3,"totalCents":6000}.
Now try the calls the policies reject:
node mcp.mjs ironflow_invoke_function '{"function_id":"cancel-orders"}'echo $statusnode mcp.mjs ironflow_emit_event '{"name":"orders.cancel.requested","data":"{}"}'echo $statusBoth calls should print permission_denied with exit code 1. These are live
tool calls against the authenticated engine, rather than only CEL expression
tests.
What this key still permits
Section titled “What this key still permits”The key can list and read functions and read runs in env_default, including
runs it did not start. The function policy narrows direct InvokeFunction and
InvokeFunctionSync calls. It does not limit the effects of the allowed
function’s code.
The empty-resource guard also lets through other routes that check
functions:invoke with an empty resource, including the worker stream. This is
not a complete sandbox for an untrusted client. The separate action policy
rejects actions outside its list, including event emission, function
registration, run mutation, API-key management, and agent-tool invocation;
it does not distinguish routes that share an allowed action. See
function-scoped key limits.
For a hosted agent, the remote endpoint is https://<engine>/api/v1/mcp.
It requires a bearer API key. A TLS reverse proxy must preserve the
Authorization header. This local exercise uses loopback HTTP; it does not
configure a public deployment or a provider account. Remote MCP also omits
the local terminal bridge. See the
remote MCP guide for deployment details.
Revoke the demonstration keys
Section titled “Revoke the demonstration keys”The third terminal still holds the setup admin credential in
IRONFLOW_API_KEY. Delete both client keys after the experiment:
ironflow apikey delete $reader_key_idironflow apikey delete $agent_key_idnode mcp.mjs ironflow_list_functions '{}'echo $statusrm reader-key.txt agent-key.txtset -e MCP_API_KEYThe final call uses the deleted developer key and fails with MCP HTTP 401;
its exit code is 1. Stop the worker and engine with Ctrl+C in their
terminals. The demo keys are revoked, while your local database remains for
inspection.