Jido.MCP

Hex.pm Hex Docs CI License Website Ecosystem Discord

jido_mcp integrates MCP servers into the Jido ecosystem. ExMCP provides the client and server protocol runtime. The public Jido API keeps endpoint pooling, response envelopes, actions, and explicit server allowlists stable.

Features

  • Shared pooled MCP clients per configured endpoint
  • Consume-side API for MCP tools/resources/prompts
  • Jido actions + plugin routes for signal-driven usage
  • Jido.AI runtime tool sync (MCP tools -> proxy Jido.Actions)
  • MCP server bridge (use Jido.MCP.Server) with explicit allowlists and a Jido-owned Plug host adapter

Installation

def deps do
  [
    {:jido_mcp, "~> 0.1"}
  ]
end

Endpoint Configuration

config :jido_mcp, :endpoints,
  github: %{
    transport: {:streamable_http, [base_url: "http://localhost:8080", mcp_path: "/mcp"]},
    client_info: %{name: "my_app", version: "1.0.0"},
    protocol_version: "2025-06-18",
    capabilities: %{},
    timeouts: %{request_ms: 30_000}
  },
  local_fs: %{
    transport: {:stdio, [command: "uvx", args: ["mcp-server-filesystem"]]},
    client_info: %{name: "my_app", version: "1.0.0"}
  }

Supported transports:

  • {:stdio, keyword()} for shell/command-based MCP servers
  • {:shell, keyword()} as an alias normalized to :stdio
  • {:streamable_http, keyword()} for MCP Streamable HTTP
  • {:beam, keyword()} for a client and server in the same BEAM instance

For Streamable HTTP, :url or a :base_url containing a non-root path is normalized to the existing :base_url + :mcp_path option shape before it is mapped to ExMCP.

The deprecated {:sse, keyword()} client transport is not supported. Migrate these endpoints to Streamable HTTP.

ExMCP client safety

config :jido_mcp, :endpoints,
  remote: %{
    transport:
      {:streamable_http,
       [
         base_url: "https://mcp.example/mcp",
         headers: [{"Authorization", "Bearer ..."}]
       ]},
    client_info: %{name: "my_app", version: "1.0.0"}
  }

ExMCP tool calls do not use the normal request retry policy. Stream recovery uses http_stream_retry: :safe_only, and retry_safe is false by default. Only set retry_safe: true when the tool has a tested idempotency contract.

Advanced ExMCP options can be supplied with :client_options. Transport, credential, timeout, lifecycle, and retry controls remain protected and must use the documented endpoint fields.

See the ExMCP migration guide for transport and server mapping, authorization, compatibility limits, and release gates.

Endpoint config may also be loaded through an MFA callback:

config :jido_mcp, :endpoints, {MyApp.MCPConfig, :endpoints, []}

The callback must return a map/keyword endpoint declaration or {:ok, endpoints}.

Runtime endpoints can be registered after application start:

{:ok, endpoint} =
  Jido.MCP.Endpoint.new(:github, %{
    transport: {:streamable_http, [base_url: "http://localhost:8080", mcp_path: "/mcp"]},
    client_info: %{name: "my_app", version: "1.0.0"}
  })

{:ok, ^endpoint} = Jido.MCP.register_endpoint(endpoint)

Runtime registration is process-local, rejects duplicate endpoint ids, and starts the MCP client only when the endpoint is first used. Atom and string ids with the same text are duplicates. String ids must be valid UTF-8 without ASCII control characters and cannot exceed 255 bytes.

Runtime Endpoint Lifecycle

{:ok, endpoint} =
  Jido.MCP.Endpoint.new(:runtime_demo, %{
    transport: {:streamable_http, [base_url: "http://localhost:8080/mcp"]},
    client_info: %{name: "my_app"}
  })

{:ok, ^endpoint} = Jido.MCP.register_endpoint(endpoint)
{:ok, tools} = Jido.MCP.list_tools(:runtime_demo)

{:ok, _removed} = Jido.MCP.unregister_endpoint(:runtime_demo)

For config changes at runtime, unregister then register the updated endpoint.

Consume MCP APIs

{:ok, tools} = Jido.MCP.list_tools(:github)
{:ok, called} = Jido.MCP.call_tool(:github, "search_issues", %{"query" => "label:bug"})

{:ok, resources} = Jido.MCP.list_resources(:github)
{:ok, content} = Jido.MCP.read_resource(:github, "repo://owner/name/README")

{:ok, prompts} = Jido.MCP.list_prompts(:github)
{:ok, prompt} = Jido.MCP.get_prompt(:github, "release_notes", %{"version" => "1.2.0"})

All calls return normalized envelopes:

  • success: %{status: :ok, endpoint: atom() | String.t(), method: String.t(), data: map(), raw: ...}
  • error: %{status: :error, endpoint: atom() | String.t(), type: ..., message: String.t(), details: ...}

Jido Actions + Plugin

Actions

  • Jido.MCP.Actions.ListTools
  • Jido.MCP.Actions.CallTool
  • Jido.MCP.Actions.ListResources
  • Jido.MCP.Actions.ListResourceTemplates
  • Jido.MCP.Actions.ReadResource
  • Jido.MCP.Actions.ListPrompts
  • Jido.MCP.Actions.GetPrompt
  • Jido.MCP.Actions.RegisterEndpoint
  • Jido.MCP.Actions.RefreshEndpoint
  • Jido.MCP.Actions.UnregisterEndpoint
  • Jido.MCP.Actions.SetDefaultEndpoint

Plugin

defmodule MyApp.Agent do
  use Jido.Agent,
    name: "assistant",
    plugins: [
      {Jido.MCP.Plugins.MCP,
        %{
          default_endpoint: :github,
          allowed_endpoints: [:github, :local_fs]
          # or allowed_endpoints: :all
        }}
    ]
end

allowed_endpoints defaults to [] (deny-all) when omitted. Set it to :all to allow all currently configured/runtime-registered endpoints.

Signal routes:

  • mcp.tools.list
  • mcp.tools.call
  • mcp.resources.list
  • mcp.resources.templates.list
  • mcp.resources.read
  • mcp.prompts.list
  • mcp.prompts.get
  • mcp.endpoint.register
  • mcp.endpoint.refresh
  • mcp.endpoint.unregister
  • mcp.endpoint.default.set

To update the plugin default endpoint at runtime, emit mcp.endpoint.default.set with %{endpoint_id: "github"} (or nil/omitted to clear).

Jido.AI Sync

Jido.MCP.JidoAI.Actions.SyncToolsToAgent discovers remote tools and creates proxy Jido.Action modules, then registers them on a running Jido.AI.Agent.

Jido.MCP.JidoAI.Actions.UnsyncToolsFromAgent removes previously synced proxies.

Dynamic Jido AI proxy modules require a trusted atom endpoint id. String endpoint ids remain available for normal MCP calls and actions, but proxy sync rejects them because Elixir module names are atoms.

Tool sync uses deterministic MCP readiness: endpoint calls wait on ExMCP readiness before they execute.

Plugin route support:

  • mcp.ai.sync_tools
  • mcp.ai.unsync_tools

Host Runtime Orchestration (Signals)

Host projects are expected to orchestrate runtime endpoint lifecycle and tool sync explicitly using plugin signals.

Recommended signal sequences:

  • Register endpoint then sync tools
    1. mcp.endpoint.register
    2. mcp.ai.sync_tools
  • Refresh endpoint then resync tools
    1. mcp.endpoint.refresh
    2. mcp.ai.sync_tools (with replace_existing: true)
  • Unsync tools before unregistering endpoint
    1. mcp.ai.unsync_tools
    2. mcp.endpoint.unregister

Example signal payloads:

# mcp.endpoint.register
%{
  endpoint_id: "runtime_demo",
  endpoint: %{
    transport: {:streamable_http, [base_url: "http://localhost:8080/mcp"]},
    client_info: %{name: "my_app"}
  }
}

# mcp.ai.sync_tools
%{
  endpoint_id: "runtime_demo",
  agent_server: :my_ai_agent,
  replace_existing: true,
  prefix: "mcp_"
}

# mcp.ai.unsync_tools
%{endpoint_id: "runtime_demo", agent_server: :my_ai_agent}

# mcp.endpoint.unregister
%{endpoint_id: "runtime_demo"}

Expose Jido As MCP Server

Server module

defmodule MyApp.MCPServer do
  use Jido.MCP.Server,
    name: "my-app",
    version: "1.0.0",
    publish: %{
      tools: [MyApp.Actions.SearchIssues],
      resources: [MyApp.MCP.Resources.ReleaseNotes],
      prompts: [MyApp.MCP.Prompts.CodeReview]
    }
end

Publication is explicit allowlist only.

Supervision

children =
  Jido.MCP.Server.server_children(MyApp.MCPServer,
    transport: :stdio
  )

Use server_children/2 for stdio or local BEAM transports. A Plug host starts per-request handlers and does not need this HTTP child.

Router (streamable HTTP)

forward "/mcp", Jido.MCP.Server.Plug,
  Jido.MCP.Server.plug_init_opts(MyApp.MCPServer,
    request_context: fn conn, _request ->
      with {:ok, grant} <- MyApp.Grants.authorize(conn) do
        {:ok,
         %{
           principal_id: grant.id,
           tenant_id: grant.tenant_id,
           assigns: %{grant_id: grant.id, grant_revision: grant.revision}
         }}
      end
    end,
    limits: [
      allowed_hosts: ["mcp.example.com"],
      allowed_origins: ["https://app.example.com"],
      body_bytes: 1_000_000,
      response_bytes: 1_000_000,
      handler_deadline_ms: 10_000,
      max_sessions_per_identity: 8,
      idle_session_ttl_ms: 900_000
    ]
  )

request_context runs before ExMCP resolves or creates a session for every POST and DELETE. It must authenticate the current caller. It returns only redacted assigns and stable principal and tenant strings. The adapter removes assign keys that name bearer, authorization, credential, password, secret, or token values. It never passes the Plug connection or request headers to a Jido action. ExMCP binds a session to the principal and tenant values, and DELETE terminates that session.

max_sessions_per_identity atomically limits sessions for one principal-and-tenant pair. idle_session_ttl_ms closes a tracked session after its idle period. You can set either limit independently. A failed initialize releases its admission reservation; an abandoned reservation also has a bounded lifetime. Lifecycle events include redacted :session_created, :session_deleted, and :session_invalidated events. An invalidated event contains a safe reason such as :revoked, :expired, or :revision_changed.

To reject a request and invalidate its current session, return this shape from request_context:

{:error,
 %{invalidate_session: %{principal_id: principal_id, tenant_id: tenant_id},
   reason: :revoked}}

If the current credential has no usable principal, it can instead target a trusted prior grant family:

{:error,
 %{invalidate_session: %{session_family_id: grant_id, tenant_id: tenant_id},
   reason: :revoked}}

The adapter verifies that the supplied identity matches the session before it closes the session. The family form first verifies the tracked prior exact identity. To close an earlier revision of one grant, include a stable session_family_id in each successful host context. A changed principal in the same tenant and family closes the old session with :revision_changed. A different family cannot close it. Other rejected requests keep the generic rejection body.

Use lifecycle: fn event -> ... end for bounded, redacted request-finished and session-deleted events. Set lifecycle_timeout_ms when the default 1 second is not suitable. Jido.MCP.Server.Plug.validate_options/1 returns the stable secret-free error %{reason: :invalid_options, details: %{field: field}} for unsafe host options.

Resource and Prompt Behaviours

  • Jido.MCP.Server.Resource
  • Jido.MCP.Server.Prompt

Implement those behaviours for items listed in publish.resources and publish.prompts.

Testing

mix test