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
Current section
Files
conduit_mcp
README.md
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