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