Jido.MCP
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.ListToolsJido.MCP.Actions.CallToolJido.MCP.Actions.ListResourcesJido.MCP.Actions.ListResourceTemplatesJido.MCP.Actions.ReadResourceJido.MCP.Actions.ListPromptsJido.MCP.Actions.GetPromptJido.MCP.Actions.RegisterEndpointJido.MCP.Actions.RefreshEndpointJido.MCP.Actions.UnregisterEndpointJido.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.listmcp.tools.callmcp.resources.listmcp.resources.templates.listmcp.resources.readmcp.prompts.listmcp.prompts.getmcp.endpoint.registermcp.endpoint.refreshmcp.endpoint.unregistermcp.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_toolsmcp.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
mcp.endpoint.registermcp.ai.sync_tools
- Refresh endpoint then resync tools
mcp.endpoint.refreshmcp.ai.sync_tools(withreplace_existing: true)
- Unsync tools before unregistering endpoint
mcp.ai.unsync_toolsmcp.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.ResourceJido.MCP.Server.Prompt
Implement those behaviours for items listed in publish.resources and publish.prompts.
Testing
mix test
No comments yet
Be the first to share your take.