Architectural Decision: How Workflows Execute Actions

The Question

When a workflow step needs to do something – send a notification, write to the knowledge base, update a CRM contact – how should the executor invoke it? Three options were considered:

  1. MCP tools – the executor calls the same tool dispatch layer every agent uses
  2. Whitelisted functions – the executor calls named Elixir functions from an approved list
  3. Callback modules – each step names an Elixir module implementing a run/2 behaviour

The decision is Option 1: MCP tools only. This document explains why.


Option 2 Was Rejected: Whitelisted Functions Are a Security Trap

The whitelist pattern looks safe at first. You enumerate the functions that workflows are allowed to call, and the executor only dispatches to those. Problem solved.

The problem is what happens after. Once a function is on the whitelist, there is no mechanism to modify its permission. You cannot say “this function is now restricted to admins” without changing code and redeploying. You cannot say “user X can call this function but user Y cannot” – the whitelist is binary: on or off, for everyone.

Compare that to the MCP permission system. Every tool is gated by a granular permission key. Access is granted or revoked at runtime per user. An agent that previously had access to knowledge_add can have that permission revoked in seconds, without touching code. The change is logged in the audit trail. The new state is immediately enforced.

Whitelisted functions undercut this entirely. A workflow calling a whitelisted function bypasses the permission system. That function executes with whatever context the workflow executor has – not with the creating user’s permissions. There is no audit log entry at the tool level. There is no way for an admin to see “workflow X called function Y on behalf of user Z” in the access control audit.

The whitelist also creates a second inventory of capabilities to maintain alongside the MCP tool registry. Every time a new function is added to the whitelist, it is a security surface that exists outside the governance layer. The MCP registry is audited. The whitelist is not.


Option 3 Was Rejected: Callback Modules Add Ceremony Without Benefit

Callback modules – where each step implements a run/2 behaviour – give maximum flexibility. They also mean that every new workflow action requires writing an Elixir module, deploying it, and registering it. You cannot define a new action from a REST call or an MCP tool invocation. An agent cannot compose new workflow actions without a code change.

This is the right model for a framework. It is not the right model for an application where agents and humans define workflows at runtime.


Option 1 Is Correct: MCP Tools Compose Cleanly

The workflow executor calls MyMCPServer.Manager.call_tool/3. This is the same dispatch path that every MCP client, every REST API call, and every IEx call goes through. The consequences:

Permission inheritance is automatic. Every tool call is gated by the creating user’s permissions. If the user who started the workflow doesn’t have knowledge_add permission, the step fails – the same way it would fail if that user tried to call knowledge_add directly. No separate permission model to maintain.

Audit trails are free. Every tool call goes through MCP.Telemetry, which records who called what and when. Workflow steps appear in the audit log exactly like direct tool calls. An admin can see that a workflow step called knowledge_add on behalf of james at 14:32:07, and what the result was.

Tools are testable independently. A new tool can be tested directly from IEx, from an MCP client, or from a REST endpoint before being used in a workflow step. The workflow adds no new failure modes. If the tool works standalone, it works in a workflow.

Agents can use tools without workflows. An agent that needs to search the knowledge base does not need to define a workflow. It calls knowledge_search directly. Workflows are for multi-step orchestration with persistence and retry – not the only way to call a tool. This keeps the tool layer clean and reusable.

New capabilities are immediately available to workflows. Add a new MCP tool for WebSocket notifications, email, Slack, or any other integration. It is immediately available as a workflow step. No changes to the executor. No whitelist update. No new module.


The Performance Question

The concern about HTTP call overhead is valid – but it does not apply here.

MyMCPServer.Manager.call_tool/3 is a GenServer call. It dispatches to handle_tool_call/3 in the same BEAM process group, serialized through the Manager’s message queue. There is no HTTP request, no socket, no serialization to JSON and back. The call is in-process.

The path is:

WorkflowExecutor (Task process)
  -> GenServer.call(MyMCPServer.Manager, {:call_tool, name, args, user_id})
  -> MyMCPServer.handle_tool_call(name, args, state)
  -> Permission check (map lookup, microseconds)
  -> Business logic function
  -> {:reply, result, state}

The overhead compared to calling the business logic function directly is one GenServer round-trip and a permission map lookup. For any tool that does meaningful work – a database query, an external API call, a vector search – this overhead is unmeasurable in context.

The only scenario where this overhead matters is a tight loop calling trivial tools thousands of times per second. That is not what workflows are for. Workflows coordinate multi-step processes where steps take seconds, not microseconds.


Implementation Consequence: Wrap New Capabilities as MCP Tools

If a workflow step needs to do something that isn’t an MCP tool yet – notify a user via WebSocket, send an email, write a file – the correct response is to add an MCP tool for it.

This is not extra work. It is the right decomposition. The tool exists independently of the workflow. It can be called by agents, by REST clients, by IEx, or by other workflows. It is permission-gated from day one. It appears in the audit log. It can be granted or revoked per user without a code change.

The workflow executor is not a general-purpose function runner. It is an orchestrator that sequences permissioned, auditable, independently-testable actions. MCP tools are exactly that.


Summary

Property MCP tools Whitelisted functions Callback modules
Permission inheritance Yes – user’s permission map No – bypasses permission system No – requires custom logic
Runtime grant/revoke Yes No No
Audit trail Yes – via MCP telemetry No No
Testable independently Yes Sometimes Requires module deployment
Agent access without workflow Yes No No
New capability requires code deploy No (new tool = new handler) Yes (whitelist update) Yes (new module)
HTTP overhead No – in-process GenServer call No No
Governance surface Single registry Two registries Two registries

The MCP tool model is not a constraint. It is the governance layer that makes agent actions trustworthy. Workflows inherit that governance for free.