Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

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.

The caller's key follows the MCP tool call An MCP client sends its bearer key to the remote endpoint. The endpoint forwards that key to the engine API. RBAC and enabled CEL deny policies check the call before a function run can start. MCP clientIts own bearer keyRemote MCP/api/v1/mcpEngine APIRBAC + CEL deniesbefore invocationSame keySame key

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.

Terminal window
mkdir remote-mcp-demo
cd remote-mcp-demo
ironflow version
set -e IRONFLOW_DATABASE_URL
set -e NATS_URL
set -e NATS_CREDS_FILE
set -e NATS_STORE_DIR
set -e NATS_STREAM_REPLICAS
set -e IRONFLOW_BOOTSTRAP_ADMIN_KEY
set -e IRONFLOW_BOOTSTRAP_ADMIN_PASSWORD
set -gx IRONFLOW_MCP_REMOTE true
set -gx IRONFLOW_POLICY_ENFORCEMENT true
ironflow serve --host 127.0.0.1 --port 9123 --nats-port -1

Keep 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.

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:

Terminal window
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123
set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')
set -e IRONFLOW_ENV
npm init -y
npm install --save-exact @ironflow/node@0.43.1

The 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.

Terminal window
node worker.mjs

Wait 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.

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.

Terminal window
set -gx IRONFLOW_SERVER_URL http://127.0.0.1:9123
set -gx IRONFLOW_API_KEY (node -p 'JSON.parse(require("node:fs").readFileSync(".ironflow/.ironflow_bootstrap_key.json", "utf8")).key')
set -e IRONFLOW_ENV
umask 077
ironflow role list
ironflow apikey create mcp-reader --role role_viewer --env env_default --expires 1h > reader-key.txt
ironflow apikey create mcp-summary --role role_developer --env env_default --expires 1h > agent-key.txt
set 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.

Terminal window
set summary_irn irn:ironflow:org_default:proj_default_default:function:env_default:order-summary
set cancel_irn irn:ironflow:org_default:proj_default_default:function:env_default:cancel-orders
set 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:

Terminal window
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.

Two checks for direct function invocation The route check passes its empty resource through the guard. The handler then checks the full function IRN. The summary function is allowed, while the cancellation function is denied. Separately, the action policy denies event emission. Route checkEmpty resourceGuard passes it throughHandler checkFull function IRNCompare against the summary IRNorder-summaryAllowedcancel-ordersDeniedSeparate action policy: events:emit is denied

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:

Terminal window
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 $status

The 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:

Terminal window
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.json
set 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:

Terminal window
node mcp.mjs ironflow_invoke_function '{"function_id":"cancel-orders"}'
echo $status
node mcp.mjs ironflow_emit_event '{"name":"orders.cancel.requested","data":"{}"}'
echo $status

Both calls should print permission_denied with exit code 1. These are live tool calls against the authenticated engine, rather than only CEL expression tests.

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.

The third terminal still holds the setup admin credential in IRONFLOW_API_KEY. Delete both client keys after the experiment:

Terminal window
ironflow apikey delete $reader_key_id
ironflow apikey delete $agent_key_id
node mcp.mjs ironflow_list_functions '{}'
echo $status
rm reader-key.txt agent-key.txt
set -e MCP_API_KEY

The 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.