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.Action per 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_mcp resolved through mix 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
end

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

  1. Endpoint id. Endpoints are registered with Jido.MCP.register_endpoint/1 and addressed by an atom id. The DSL stores the id, not the endpoint struct, so compile and runtime stay decoupled.
  2. Tool discovery. When tools: is empty the runtime calls Jido.MCP.list_tools/2 on the endpoint to enumerate available tools. required: true makes 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.
  3. 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 bare mcp_tools endpoint: :demo_mcp exposes 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/1 on remote tool names. The slug goes through Macro.underscore/1 and 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
end

Step 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"}
            ]
end

Static 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_"
end

If 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
end

required: 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 :idempotent only when the server promises it. The default idempotency: :idempotent is correct for read-only tools. Set idempotency: :unsafe_once (or stricter) for tools that mutate.
  • Use prefixes to encode trust. A prefix like internal_ versus external_ 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
end

Tests 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

SymptomLikely CauseFix
{: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 turnThe 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 timeA static tools: entry was malformed.Ensure each entry is a map with at least name:.
Operation name unexpectedly differs from the remote toolThe 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_tools entity (endpoint, prefix, tools, required, timeout, description, idempotency, metadata).
  • Jido.MCP - public MCP client API.
  • Jido.MCP.Endpoint - endpoint registration.