# Jido.MCP

[![Hex.pm](https://img.shields.io/hexpm/v/jido_mcp.svg)](https://hex.pm/packages/jido_mcp)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/jido_mcp/)
[![CI](https://github.com/agentjido/jido_mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/agentjido/jido_mcp/actions/workflows/ci.yml)
[![License](https://img.shields.io/hexpm/l/jido_mcp.svg)](https://github.com/agentjido/jido_mcp/blob/main/LICENSE)
[![Website](https://img.shields.io/badge/website-jido.run-0f172a.svg)](https://jido.run)
[![Ecosystem](https://img.shields.io/badge/ecosystem-jido.run-0ea5e9.svg)](https://jido.run/ecosystem)
[![Discord](https://img.shields.io/badge/discord-join-5865F2.svg?logo=discord&logoColor=white)](https://jido.run/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.Action`s)
- MCP server bridge (`use Jido.MCP.Server`) with explicit allowlists and a
  Jido-owned Plug host adapter

## Installation

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

## Endpoint Configuration

```elixir
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

```elixir
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](guides/ex_mcp_migration.md) for transport and
server mapping, authorization, compatibility limits, and release gates.

Endpoint config may also be loaded through an MFA callback:

```elixir
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:

```elixir
{: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

```elixir
{: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

```elixir
{: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

```elixir
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:

```elixir
# 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

```elixir
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

```elixir
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)

```elixir
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`:

```elixir
{: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:

```elixir
{: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

```bash
mix test
```
