This guide explains how to expose Model Context Protocol (MCP) servers as
agent operations through the mcp_tools DSL entity. Jidoka discovers tools
from a configured Jido.MCP endpoint, compiles them into ordinary
Jidoka.Agent.Spec.Operation entries, and routes each operation call back to
the remote MCP tool name. By the end you will be able to register an
endpoint, list and filter tools, run a deterministic test against an injected
MCP client, and reason about the trust boundary around external servers.
When To Use This
- Use this guide when the agent needs to call tools hosted by an MCP server - internal services, third-party registries, or other Anubis-compatible endpoints.
- Use this guide when you want one DSL entry to surface every relevant tool
from one endpoint, instead of writing one
Jidoka.Actionper remote tool. - Do not use this guide for in-process tools. A
Jidoka.Action(see Getting Started) is simpler and faster. - Do not use this guide for non-MCP HTTP services. Wrap those in a workflow. See Skill, Workflow, And Subagent Tools.
Prerequisites
- A working Jidoka DSL agent. See Getting Started.
:jido_mcpresolved throughmix deps.get.- A registered MCP endpoint. Endpoints are runtime values; register them before any agent calls a tool:
{:ok, endpoint} =
Jido.MCP.Endpoint.new(:demo_mcp,
transport: {:stdio, command: "node", args: ["./mcp/server.js"]},
client_info: %{"name" => "my_app", "version" => "1.0.0"}
)
{:ok, _endpoint} = Jido.MCP.register_endpoint(endpoint)- For deterministic tests, an injected client module (see Testing).
Quick Example
The smallest MCP-backed agent declares the endpoint and lets discovery do the
rest at compile time when static tools: are provided, or at the first turn
when discovery is dynamic.
defmodule MyApp.PolicyAgent do
use Jidoka.Agent
agent :policy_agent do
model "openai:gpt-4o-mini"
instructions "Use lookup_policy to answer policy questions."
end
tools do
mcp_tools endpoint: :demo_mcp,
prefix: "mcp_",
tools: [
%{
name: "lookup_policy",
description: "Returns the latest support policy by topic.",
input_schema: %{
"type" => "object",
"properties" => %{"topic" => %{"type" => "string"}}
}
}
]
end
endThat spec exposes one operation, mcp_lookup_policy. The prefix prevents
remote names from colliding with local actions; the static tools: list
removes the need to call discovery at compile time.
Concepts
╭───────────────────────────╮
│ tools do │
│ mcp_tools endpoint: ... │
│ prefix: "mcp_"│
╰─────────────┬─────────────╯
│ Jidoka.Operation.Source.MCP.new!
▼
╭───────────────────────────╮ ╭──────────────────────────╮
│ MCP source struct │────▶│ list_tools (static or │
│ endpoint + prefix + │ │ via Jido.MCP) │
│ optional static tools │ ╰──────────┬───────────────╯
╰─────────────┬─────────────╯ │
│ ▼
│ ╭───────────────────────────────╮
│ │ Jidoka.Agent.Spec.Operation │
▼ │ name = prefix + slug │
╭───────────────────────────╮ metadata.source = "mcp" │
│ routed_capability │ metadata.remote_tool = name │
│ intent.name -> remote │ ╰───────────────┬─────────────╯
│ Jido.MCP.call_tool/4 │ │
╰─────────────┬─────────────╯ │ turn loop
│ ▼
▼ ╭───────────────────────────╮
remote MCP server │ same effect path as │
│ deterministic operations │
╰───────────────────────────╯Three concepts cover this integration:
- Endpoint id. Endpoints are registered with
Jido.MCP.register_endpoint/1and addressed by an atom id. The DSL stores the id, not the endpoint struct, so compile and runtime stay decoupled. - Tool discovery. When
tools:is empty the runtime callsJido.MCP.list_tools/2on the endpoint to enumerate available tools.required: truemakes discovery failures hard errors; the default (false) treats a discovery failure as "no tools" and lets the agent keep running with whatever local operations remain. - Name routing. Each compiled operation has a slugged local name (e.g.
mcp_lookup_policy). The capability maps that local name back to the remote tool name at call time. The model never sees the raw remote name.
Security / Trust Boundaries
- MCP endpoints are external code paths. Treat every tool response as untrusted input: validate before you store, log, or pass it to another operation.
- The DSL trusts the
endpoint:atom you provide. Never derive it from user input; resolve through your own allowlist of registered endpoints first. - The
tools:filter is the production allowlist. A baremcp_tools endpoint: :demo_mcpexposes every tool the server advertises, including newly added ones after a server upgrade. Pin the list when you need reviewable change control. - Credentials for the MCP transport live in
Jido.MCP.Endpoint, not in the agent spec or in operation metadata. They are never serialized into snapshots or imports. - The runtime never calls
String.to_atom/1on remote tool names. The slug goes throughMacro.underscore/1and a strict regex filter; injected names cannot escalate into new atoms.
How To
Step 1: Register The Endpoint At Application Boot
Endpoints are runtime state. Register them before any agent starts a turn.
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
{:ok, _} =
:demo_mcp
|> Jido.MCP.Endpoint.new!(
transport: {:stdio, command: "node", args: ["./mcp/server.js"]},
client_info: %{"name" => "my_app", "version" => "1.0.0"}
)
|> Jido.MCP.register_endpoint()
Supervisor.start_link([Jidoka.Jido], strategy: :one_for_one, name: MyApp.Supervisor)
end
endStep 2: Pin The Tool List
When the server may advertise many tools, list the ones the agent should actually use.
tools do
mcp_tools endpoint: :demo_mcp,
prefix: "mcp_",
tools: [
%{name: "lookup_policy"},
%{name: "list_topics"}
]
endStatic tool entries can be sparse maps. Only name: is required; descriptions
and input_schema fill in from discovery when they are absent.
Step 3: Add A Prefix To Avoid Name Collisions
Two endpoints may advertise tools with the same name. Use prefixes to keep operation names unique across an agent.
tools do
mcp_tools endpoint: :customer_mcp, prefix: "cust_"
mcp_tools endpoint: :inventory_mcp, prefix: "inv_"
endIf prefix: is omitted, the source uses mcp_<endpoint_slug>_ so the
default already keeps endpoints disjoint.
Step 4: Make Discovery Required In Production
By default a discovery failure returns "no tools" and the agent continues. Production code that depends on MCP being live should fail fast.
tools do
mcp_tools endpoint: :customer_mcp, required: true, timeout: 5_000
endrequired: true turns discovery errors into spec compilation errors of the
shape {:mcp_tool_discovery_failed, endpoint, reason}.
Step 5: Run A Deterministic Test With An Injected Client
The MCP source accepts a :client override and the runtime context accepts
mcp_client: so tests can run without a real server.
defmodule FakeMCPClient do
def list_tools(:demo_mcp, _opts) do
{:ok,
%{data: %{"tools" => [%{"name" => "lookup_policy"}]}}}
end
def call_tool(:demo_mcp, "lookup_policy", args, _opts) do
{:ok, %{data: %{"topic" => args["topic"], "policy" => "Use the fake."}}}
end
end
llm = fn _intent, journal ->
llm_calls = Enum.count(journal.results, fn {_id, r} -> r.kind == :llm end)
case llm_calls do
0 ->
{:ok,
%{type: :operation, name: "mcp_lookup_policy",
arguments: %{"topic" => "runtime"}}}
1 ->
{:ok, %{type: :final, content: "Policy is to use the fake."}}
end
end
{:ok, result} =
Jidoka.turn(MyApp.PolicyAgent, "What is the runtime policy?",
llm: llm,
context: %{mcp_client: FakeMCPClient}
)Common Patterns
- Treat MCP tools as
:idempotentonly when the server promises it. The defaultidempotency: :idempotentis correct for read-only tools. Setidempotency: :unsafe_once(or stricter) for tools that mutate. - Use prefixes to encode trust. A prefix like
internal_versusexternal_makes the trust boundary visible in logs, traces, and the prompt. - Pin
tools:for any agent that ships to production. Use discovery for local development and CI smoke tests. - Combine with controls.
operation MyControl, when: [source: "mcp", endpoint: "demo_mcp"]lets you gate every tool from one endpoint with one control.
Testing
The MCP test suite is the canonical reference. See
test/jidoka/mcp_test.exs for the full pattern. A small
deterministic test looks like this:
defmodule MyApp.PolicyAgentTest do
use ExUnit.Case, async: true
defmodule FakeMCPClient do
def list_tools(:demo_mcp, _opts),
do: {:ok, %{data: %{"tools" => [%{"name" => "lookup_policy"}]}}}
def call_tool(:demo_mcp, "lookup_policy", _args, _opts),
do: {:ok, %{data: %{"policy" => "ok"}}}
end
test "lookup_policy round trip" do
llm = fn _intent, journal ->
llm_calls = Enum.count(journal.results, fn {_id, r} -> r.kind == :llm end)
case llm_calls do
0 ->
{:ok,
%{type: :operation, name: "mcp_lookup_policy",
arguments: %{"topic" => "runtime"}}}
1 ->
{:ok, %{type: :final, content: "Policy is ok."}}
end
end
assert {:ok, result} =
Jidoka.turn(MyApp.PolicyAgent, "Runtime policy?",
llm: llm,
context: %{mcp_client: FakeMCPClient}
)
assert result.content =~ "ok"
end
endTests should never call out to a real MCP server. Use the client override on
the source or the mcp_client: context key, whichever is more convenient.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
{:error, {:mcp_tool_discovery_failed, endpoint, reason}} | required: true and discovery failed. | Register the endpoint at boot, or set required: false while iterating. |
{:error, {:missing_operation_handler, name}} from a turn | The model called a tool name that did not exist in the routed source. | Confirm the operation name with Jidoka.inspect/1; tighten the prompt or pin tools:. |
{:error, {:invalid_mcp_client, client}} | The supplied client module did not export list_tools/2 and call_tool/4. | Implement the two functions on the double, or fall back to the default Jido.MCP. |
{:error, {:invalid_mcp_tool, tool}} at compile time | A static tools: entry was malformed. | Ensure each entry is a map with at least name:. |
| Operation name unexpectedly differs from the remote tool | The prefix plus slug rewrite produced a different name. | Inspect metadata.remote_tool to see the original name and adjust the prefix or name: overrides. |
Reference
Key modules touched in this guide:
Jidoka.Operation.Source.MCP- struct, normalization, discovery, and routed capability.Jidoka.Operation.Source- the behaviour and compiler all operation sources share.- Tool DSL section - DSL
schema for the
mcp_toolsentity (endpoint,prefix,tools,required,timeout,description,idempotency,metadata). Jido.MCP- public MCP client API.Jido.MCP.Endpoint- endpoint registration.
Related Guides
- Getting Started - the smallest DSL agent end to end.
- Skill, Workflow, And Subagent Tools -
the three other DSL-level operation sources that share
Jidoka.Operation.Source. - AshJido Resources - a sibling source for resource-backed tools.
- Browser Tools - a sibling source for constrained read-only browsing.
- Idempotency And Safety - why MCP defaults to
:idempotentand when to override.