Packages

Elixir implementation of the Model Context Protocol (MCP) specification. Build MCP servers to expose tools, resources, and prompts to LLM applications like Claude Desktop and VS Code extensions. Supports both Streamable HTTP and SSE transports with configurable authentication and CORS.

Current section

Files

Jump to
conduit_mcp README.md
Raw

README.md

# ConduitMCP

An Elixir implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) specification (version 2025-06-18). Build MCP servers to expose tools, resources, and prompts to LLM applications like Claude Desktop and VS Code extensions.

## Installation

Add to your `mix.exs`:

```elixir
def deps do
  [
    {:conduit_mcp, "~> 0.4.5"}
  ]
end
```

Or from GitHub:

```elixir
def deps do
  [
    {:conduit_mcp, github: "nyo16/conduit_mcp"}
  ]
end
```

## What's New in v0.4.0

**🚀 Stateless Architecture for Maximum Concurrency**

- Removed GenServer bottleneck - all requests now processed concurrently
- Callbacks simplified - no more state passing/returning
- Config initialized once and stored immutably
- Each HTTP request runs in parallel (limited only by Bandit's process pool)

**⚠️ Breaking Changes**

The API has been simplified. Update your callbacks:

```elixir
# v0.3.0 (old)
def handle_list_tools(state) do
  {:reply, %{"tools" => state.tools}, state}
end

# v0.4.0 (new)
def handle_list_tools(config) do
  {:ok, %{"tools" => config.tools}}
end
```

See the migration guide below for details.

## Quick Start

### Standalone Server

```elixir
defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  @tools [
    %{
      "name" => "greet",
      "description" => "Greet someone",
      "inputSchema" => %{
        "type" => "object",
        "properties" => %{
          "name" => %{"type" => "string"}
        },
        "required" => ["name"]
      }
    }
  ]

  @impl true
  def handle_list_tools(_conn) do
    {:ok, %{"tools" => @tools}}
  end

  @impl true
  def handle_call_tool(_conn, "greet", %{"name" => name}) do
    {:ok, %{
      "content" => [%{"type" => "text", "text" => "Hello, #{name}!"}]
    }}
  end
end
```

Start the server:

```elixir
children = [
  # No need to start the server module - it's just functions!
  {Bandit,
   plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
   port: 4001}
]

Supervisor.start_link(children, strategy: :one_for_one)
```

### Phoenix Integration

Add MCP endpoints directly to your Phoenix application:

```elixir
# lib/my_app/mcp_server.ex
defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  @tools [
    %{
      "name" => "get_user",
      "description" => "Get user information from database",
      "inputSchema" => %{
        "type" => "object",
        "properties" => %{
          "user_id" => %{"type" => "string"}
        },
        "required" => ["user_id"]
      }
    }
  ]

  @impl true
  def handle_list_tools(_conn) do
    {:ok, %{"tools" => @tools}}
  end

  @impl true
  def handle_call_tool(_conn, "get_user", %{"user_id" => id}) do
    user = MyApp.Accounts.get_user(id)
    {:ok, %{
      "content" => [%{"type" => "text", "text" => "User: #{user.name}"}]
    }}
  end
end
```

Add to your router:

```elixir
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :mcp do
    # Optional: Add authentication
    plug MyAppWeb.Plugs.MCPAuth, enabled: false
  end

  scope "/mcp", MyAppWeb do
    pipe_through :mcp

    forward "/", ConduitMcp.Transport.StreamableHTTP,
      server_module: MyApp.MCPServer
  end
end
```

That's it! No need to add the server to your supervision tree - it's just a module with functions.

See the [Phoenix Integration Example](examples/phoenix_mcp/README.md) for a complete working example with authentication.

## Client Configuration

### VS Code / Cursor

Add to your MCP settings:

```json
{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:4001/"
    }
  }
}
```

### Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "my-app": {
      "command": "elixir",
      "args": ["/path/to/your/server.exs"]
    }
  }
}
```

## Migration Guide (v0.3.x → v0.4.0)

Update your callback signatures and return values:

| Callback | v0.3.x | v0.4.0 |
|----------|--------|--------|
| `mcp_init/1` | Required | Removed (use module attributes instead) |
| `handle_list_tools/1` | `handle_list_tools(config)` → `{:reply, result, config}` | `handle_list_tools(conn)` → `{:ok, result}` |
| `handle_call_tool/3` | `handle_call_tool(name, params, config)` → `{:reply, result, config}` | `handle_call_tool(conn, name, params)` → `{:ok, result}` |
| `handle_list_resources/1` | `handle_list_resources(config)` → `{:reply, result, config}` | `handle_list_resources(conn)` → `{:ok, result}` |
| `handle_read_resource/2` | `handle_read_resource(uri, config)` → `{:reply, result, config}` | `handle_read_resource(conn, uri)` → `{:ok, result}` |
| `handle_list_prompts/1` | `handle_list_prompts(config)` → `{:reply, result, config}` | `handle_list_prompts(conn)` → `{:ok, result}` |
| `handle_get_prompt/3` | `handle_get_prompt(name, args, config)` → `{:reply, result, config}` | `handle_get_prompt(conn, name, args)` → `{:ok, result}` |

**Key changes:**
1. No more `mcp_init/1` - use module attributes like `@tools` instead
2. Callbacks receive `conn` (Plug.Conn) as first parameter instead of config
3. Change `{:reply, result, state}` to `{:ok, result}`
4. Change `{:error, error, state}` to `{:error, error}`
5. Error maps now use string keys: `%{"code" => -32000, "message" => "..."}` instead of atoms
6. **Remove server from supervision tree** - it's just functions now!

**Example Migration:**

```elixir
# v0.3.x
defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  @impl true
  def mcp_init(_opts) do
    {:ok, %{tools: [...]}}
  end

  @impl true
  def handle_list_tools(config) do
    {:reply, %{"tools" => config.tools}, config}
  end

  @impl true
  def handle_call_tool("echo", %{"msg" => msg}, config) do
    {:reply, %{"content" => [...]}, config}
  end
end

# Supervision tree
children = [
  {MyApp.MCPServer, []},  # ← Remove this!
  {Bandit, ...}
]

# v0.4.0
defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  @tools [...]  # Define as module attribute

  @impl true
  def handle_list_tools(_conn) do
    {:ok, %{"tools" => @tools}}
  end

  @impl true
  def handle_call_tool(_conn, "echo", %{"msg" => msg}) do
    {:ok, %{"content" => [...]}}
  end
end

# Supervision tree
children = [
  {Bandit, ...}  # Just Bandit!
]
```

**Handling Mutable State**

If you need mutable state (e.g., counters, caches), use external mechanisms:

```elixir
# Option 1: ETS (fastest for concurrent reads/writes)
def handle_call_tool(_conn, "increment", _params) do
  :ets.update_counter(:my_counter, :count, 1)
  count = :ets.lookup_element(:my_counter, :count, 2)
  {:ok, %{"content" => [%{"type" => "text", "text" => "Count: #{count}"}]}}
end

# Option 2: Agent/GenServer (for complex state)
def handle_call_tool(_conn, "get_cache", %{"key" => key}) do
  value = MyApp.Cache.get(key)
  {:ok, %{"content" => [%{"type" => "text", "text" => value}]}}
end

# Option 3: Database (for persistent state)
def handle_call_tool(_conn, "save_data", %{"data" => data}) do
  MyApp.Repo.insert(%Data{value: data})
  {:ok, %{"content" => [%{"type" => "text", "text" => "Saved!"}]}}
end
```

**Using Connection Context:**

The `conn` parameter provides access to request context:

```elixir
def handle_call_tool(conn, "private_data", _params) do
  # Access authentication info
  user_id = conn.assigns[:user_id]

  # Check headers
  auth = Plug.Conn.get_req_header(conn, "authorization")

  {:ok, %{"content" => [%{"type" => "text", "text" => "User: #{user_id}"}]}}
end
```

## Features

- Full MCP specification 2025-06-18 implementation
- **Pure stateless architecture - just compiled functions!**
  - No GenServer, no Agent, no process overhead
  - No supervision tree required
  - Maximum concurrency - limited only by Bandit's process pool
- **Flexible authentication** - Bearer tokens, API keys, custom verification
- Dual transport support (Streamable HTTP and SSE)
- JSON-RPC 2.0 compliant
- Support for tools, resources, and prompts
- Connection context access for authentication/headers
- Configurable CORS and authentication
- Phoenix integration support
- Telemetry events for monitoring
- Production ready with comprehensive test coverage

## Authentication

ConduitMCP includes a flexible authentication plug supporting multiple strategies:

### Development (No Auth)

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [enabled: false]},
 port: 4001}
```

### Static Bearer Token

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [
          strategy: :bearer_token,
          token: System.get_env("MCP_SECRET_TOKEN")
        ]},
 port: 4001}
```

### Static API Key

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [
          strategy: :api_key,
          api_key: "your-api-key",
          header: "x-api-key"  # Optional, defaults to "x-api-key"
        ]},
 port: 4001}
```

### Custom Verification Function

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [
          strategy: :function,
          verify: fn token ->
            case MyApp.Auth.verify_token(token) do
              {:ok, user} -> {:ok, user}
              _ -> {:error, "Invalid token"}
            end
          end,
          assign_as: :current_user  # Optional, defaults to :current_user
        ]},
 port: 4001}
```

### Database Token Lookup

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [
          strategy: :function,
          verify: fn token ->
            case MyApp.Repo.get_by(ApiToken, token: token, active: true) do
              %ApiToken{user: user} -> {:ok, user}
              nil -> {:error, "Invalid or expired token"}
            end
          end
        ]},
 port: 4001}
```

### MFA (Module, Function, Args)

```elixir
{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP,
        server_module: MyApp.MCPServer,
        auth: [
          strategy: :function,
          verify: {MyApp.Auth, :verify_mcp_token, []}
        ]},
 port: 4001}

# In MyApp.Auth module:
def verify_mcp_token(token) do
  # Your verification logic
  {:ok, user} | {:error, reason}
end
```

### Using Authenticated User in Tools

```elixir
defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  @impl true
  def handle_call_tool(conn, "get_profile", _params) do
    # Access authenticated user from conn.assigns
    case conn.assigns[:current_user] do
      nil ->
        {:error, %{"code" => -32000, "message" => "Not authenticated"}}

      user ->
        {:ok, %{
          "content" => [%{
            "type" => "text",
            "text" => "User: #{user.name}, Email: #{user.email}"
          }]
        }}
    end
  end
end
```

## Testing

```bash
# Run tests
mix test

# Run with coverage
mix coveralls

# Test with curl
curl -X POST http://localhost:4001/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

## Documentation

- [Simple Server Example](examples/simple_tools_server/README.md)
- [Phoenix Integration Example](examples/phoenix_mcp/README.md)
- [MCP Specification](https://modelcontextprotocol.io/specification/)

## Requirements

- Elixir 1.18+
- Erlang/OTP 27+

## License

Apache License 2.0