Skip to content
GitHub

MCP, in both directions

An agent that speaks the Model Context Protocol can call a flow as a tool, and a flow can call a tool on any MCP server, HotLoop’s own included. Three nodes and one config node do it: mcp tool and mcp result serve a flow, mcp client on an hotloop-flow-mcp-server connection calls out. If you only ever wire sensors to dashboards you can skip all of this: a flow that uses none of these nodes never makes an MCP call, and Flow serves no tools it wasn’t given.

The protocol is the Go SDK’s (github.com/modelcontextprotocol/go-sdk v1.8.0, permissively licensed, with some files still MIT while the project finishes relicensing, maintained by the MCP project). It speaks the current revision of the specification, 2026-07-28, which has no handshake and carries the version on every request, and the handshake revisions through 2025-11-25, on the same endpoint. A client gets whichever it opens with, so nothing needs configuring.

An mcp tool node declares a tool: a name, a description for the model that reads it, and an input schema. While the flow is deployed Flow serves it at /mcp (under the admin root, if you moved it). A call becomes a message out of the node, msg.payload is the call’s arguments, msg.topic is the tool name, and msg.mcp holds tool, caller and callId. An mcp result node somewhere downstream sends the answer back, the way http response answers http in. Everything between them is a flow like any other.

[
{"id": "t1", "type": "tab", "label": "Line"},
{"id": "tool", "type": "mcp tool", "z": "t1", "toolName": "line_status",
"description": "Status of a production line: its state and speed.",
"schema": "{\"type\":\"object\",\"properties\":{\"line\":{\"type\":\"string\",\"enum\":[\"L1\",\"L2\",\"L3\"]}},\"required\":[\"line\"],\"additionalProperties\":false}",
"x": 100, "y": 100, "wires": [["status"]]},
{"id": "status", "type": "function", "z": "t1", "name": "status", "outputs": 1,
"func": "msg.payload = {line: msg.payload.line, state: 'running', askedBy: msg.mcp.caller};\nreturn msg;",
"x": 300, "y": 100, "wires": [["answer"]]},
{"id": "answer", "type": "mcp result", "z": "t1", "x": 500, "y": 100, "wires": []}
]

The schema is checked before the flow runs. It is JSON Schema, read the way the json node reads one (draft 2020-12 unless the schema names another, and a $ref to another URL is an error). A schema that is not valid, a root that is not an object, a tool name MCP doesn’t allow, or two nodes serving the same name fail that node’s deploy, with the reason. A call whose arguments don’t fit is answered as a tool error that names the bad field, arguments.line: value must be one of "L1", "L2", "L3", so the model can fix it and try again, and the flow never sees it. An object payload goes back as an object and as its JSON text, text goes back as text, and an mcp result fed by a Catch node, or with “The work failed” ticked, answers as a tool error with the error’s own words. A call nothing answers is told so after the reply timeout, 30 seconds unless you set it, and the error goes to a Catch node. A redeploy waits for calls in flight.

An agent is a caller like any other. /mcp takes the same tokens the admin API takes, a session token or an API token, and nothing else. There is no anonymous call: no token is a 401, a token Flow doesn’t know is a 401, and a token with no mcp.call permission is a 403, before the protocol sees the request. An instance running with HOTLOOP_FLOW_INSECURE answers 403 to every /mcp request, because a tool call has to have a caller to put in the audit log. A token for an agent is a token like any other:

auth:
tokens:
- name: agent
hash: sha256:... # from: hotloop-flow token -name agent
permissions: ["mcp.call"]
rateLimit: {calls: 60, per: 1m} # optional; no rateLimit, no limit

The call runs with that identity’s rights. Every caller needs mcp.call (or mcp.*, or *). Put another permission in a tool’s “Extra permission” box, say mcp.line3, and a caller also needs that one, and doesn’t see the tool in the list without it. Two agents can share a Flow and see different tools.

A token can be held to a number of calls. rateLimit on an API token in auth.tokens is calls in a window, per, one minute if you leave it out. The window starts with the token’s first call, a call over the number is refused until it ends, and then the count starts again. A call that is refused never reaches the flow, and is answered as a tool error that says how many seconds to wait, so an agent in a loop is told to slow down in words it reads. The specification names no error for this, so it is the tool error it already defines for a call that can’t be done. A token with no rateLimit is never throttled, and a session token isn’t limited either, because it is a person’s. Each token counts on its own, and the count lives in memory, so a restart starts it over. A refusal is an mcp.refused row with the reason rate limit exceeded, which means a flood is in the audit log too.

Every call is in the audit log, under the caller’s name. mcp.call is written before the flow runs, with the tool, the node and a call id, and mcp.result after it, with the outcome and the milliseconds. mcp.refused is a token that is wrong, one that lacks the permission, a tool it may not call, or arguments that didn’t fit. The arguments themselves are never written. A request with no token at all isn’t written either, because nobody is on the other end to name. GET /audit?event=mcp. reads all of it, and ?user=token:agent narrows it to one agent.

Try it with the MCP project’s own inspector, which needs nothing from Flow:

Terminal window
npx @modelcontextprotocol/inspector@2.12.0 --cli http://localhost:1880/mcp \
--transport http --header "Authorization: Bearer $TOKEN" \
--method tools/call --tool-name line_status --tool-arg line=L3

A flow test puts the message a call would have made where the mcp tool node sits, and mcp result hands what it would have answered to the test as {isError, text, structuredContent}, so the flow is tested without a server.

An mcp client node calls a tool when a message arrives, on an hotloop-flow-mcp-server connection: the server’s MCP address, a bearer token if it takes one, and a timeout. The tool and its arguments are typed inputs, so either can come from the message. msg.payload is the tool’s structured answer when it has one, the text when everything it said was text, and the content as it came when it was anything else, and msg.mcp has the server, the tool and the whole answer. The other operation, “List the tools”, puts every tool the server has in msg.payload with its description and input schema.

A tool that reports an error raises one, with the server’s words, so a Catch node sees what the model would have seen. A call is never sent twice. A tool may move equipment, and a dropped connection doesn’t say whether the server ran it, so the error says the connection failed and the next message connects again. The token goes to the address you gave and nowhere else, a redirect isn’t followed, and an error never repeats it. The connection trusts the system’s certificate roots and nothing else.

For HotLoop it is the address of its /mcp and the mcp-token from its release Secret, and every tool in HotLoop’s MCP docs is there:

{"id": "plant", "type": "mcp client", "z": "t1", "server": "hotloop",
"tool": "get_plant_status", "toolType": "str", "arguments": "{}", "argumentsType": "json",
"x": 300, "y": 100, "wires": [["use"]]}

HotLoop sees the call as an MCP client’s, under its own rules: MCP reads and writes, through the same gate as everybody else, so a write_tag to an armed tag in range goes through (once HotLoop’s own writes are switched on) and one it refuses, a tag nobody marked writable say, comes back as the node’s error in HotLoop’s words. To write through HotLoop’s gate from a flow without MCP, use the HotLoop nodes above.

The server is tested with the Go SDK’s own client over real HTTP, in both eras: list, call, arguments refused, a permission refused, a token refused, the audit rows, a tool that goes away when its node stops, and the whole admin server in front of it with real API and session tokens and the audit log on disk. The MCP project’s Inspector CLI 2.12.0 listed a running Flow’s tools, called them, and was refused without a token and with a made-up one. The mcp client node was run against a real HotLoop 4.18.10 (the published IoT image): it listed all 27 of its tools, called get_plant_status, list_devices and get_states and used the answers, was refused with a wrong token and with none, got HotLoop’s own error for a tool that doesn’t exist and for a write MCP isn’t allowed to make, and a Flow tool called by the Inspector ran a flow that called HotLoop’s MCP and answered with what it said. CI starts that HotLoop and runs all of it, and fails if any of it skips.

Not done: OAuth (a bearer token is the only way in, either direction), stdio servers, resources, prompts, sampling, elicitation and progress notifications, and a tool’s output schema. HotLoop 4.18.10 speaks the handshake revisions, so the node meets it that way; against a server of the current revision it is tested only with Flow’s own. A handshake server that forgets a session (a restart) fails the first call after it, once, and the next one connects again. Rate limiting is per API token and off until you set it, so a token with no rateLimit is bounded only by the queues, which already have a ceiling, and by the request size, 4 MiB.