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. Build MCP servers to expose tools, resources, and prompts to LLM applications.
[]()
[]()
## Features
- **Clean DSL** - Declarative tool definitions with automatic schema generation
- **Runtime Validation** - Parameter validation with NimbleOptions, type coercion, and custom constraints
- **Stateless Architecture** - Pure functions, no processes, maximum concurrency
- **Flexible Authentication** - Bearer tokens, API keys, custom verification
- **Full MCP Spec** - Tools, resources, prompts, and all MCP 2025-06-18 features
- **Phoenix Ready** - Drop-in integration with Phoenix applications
- **Rate Limiting** - HTTP-level and message-level rate limiting with Hammer
- **Production Ready** - Comprehensive tests, telemetry, CORS support
## Installation
```elixir
def deps do
[
{:conduit_mcp, "~> 0.6.5"}
]
end
```
## Quick Start
### Basic DSL Example
```elixir
defmodule MyApp.MCPServer do
use ConduitMcp.Server
tool "greet", "Greet someone" do
param :name, :string, "Person's name", required: true
param :style, :string, "Greeting style", enum: ["formal", "casual"]
handle fn _conn, params ->
name = params["name"]
style = params["style"] || "casual"
greeting = if style == "formal", do: "Good day", else: "Hey"
text("#{greeting}, #{name}!")
end
end
prompt "code_review", "Code review assistant" do
arg :code, :string, "Code to review", required: true
arg :language, :string, "Language", default: "elixir"
get fn _conn, args ->
[
system("You are a code reviewer"),
user("Review this #{args["language"]} code:\n#{args["code"]}")
]
end
end
resource "user://{id}" do
description "User profile"
mime_type "application/json"
read fn _conn, params, _opts ->
user = MyApp.Users.get!(params["id"])
json(user)
end
end
end
```
### Enhanced DSL with Validation
```elixir
defmodule MyApp.MCPServer do
use ConduitMcp.Server
tool "create_user", "Create a new user with validation" do
# Basic types with constraints
param :name, :string, "Full name", required: true, min_length: 2, max_length: 50
param :age, :integer, "Age", min: 0, max: 150, required: true
param :email, :string, "Email address", validator: &ConduitMcp.Validation.Validators.email/1
# Enum validation with default
param :role, :string, "User role", enum: ["admin", "user", "guest"], default: "user"
# Numeric constraints with defaults
param :score, :number, "Performance score", min: 0.0, max: 100.0, default: 50.0
handle &UserService.create/2
end
tool "calculate", "Math with validation" do
param :operation, :string, "Math operation", enum: ~w(add subtract multiply divide), required: true
param :a, :number, "First number", required: true
param :b, :number, "Second number", required: true
param :precision, :integer, "Decimal places", min: 0, max: 10, default: 2
handle fn _conn, params ->
result = case params["operation"] do
"add" -> params["a"] + params["b"]
"subtract" -> params["a"] - params["b"]
"multiply" -> params["a"] * params["b"]
"divide" -> params["a"] / params["b"]
end
formatted = Float.round(result, params["precision"])
text("Result: #{formatted}")
end
end
end
```
**Helper functions available:**
- `text(string)` - Text response
- `json(data)` - JSON response
- `raw(data)` - Raw data response (bypasses MCP wrapping, for debugging)
- `error(message)` or `error(message, code)` - Error response
- `system(content)`, `user(content)`, `assistant(content)` - Prompt messages
### Example without DSL (Manual)
```elixir
defmodule MyApp.MCPServer do
use ConduitMcp.Server, dsl: false
@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
```
### Standalone with Bandit
```elixir
# lib/my_app/application.ex
def start(_type, _args) do
children = [
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
port: 4001}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
```
### Phoenix Integration
```elixir
# lib/my_app/mcp_server.ex
defmodule MyApp.MCPServer do
use ConduitMcp.Server
alias MyApp.Accounts
tool "get_user", "Get user from database" do
param :user_id, :string, "User ID", required: true
handle fn _conn, %{"user_id" => id} ->
user = Accounts.get_user!(id)
json(%{id: user.id, name: user.name, email: user.email})
end
end
tool "search", "Search users" do
param :query, :string, "Search query", required: true
param :limit, :number, "Max results", default: 10
handle Accounts, :search_users
end
end
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
scope "/mcp" do
forward "/", ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
auth: [
strategy: :bearer_token,
token: System.get_env("MCP_AUTH_TOKEN")
]
end
end
```
## Parameter Validation
ConduitMCP v0.6.0+ includes comprehensive runtime parameter validation using [NimbleOptions](https://hexdocs.pm/nimble_options). Validation includes type checking, constraints, and automatic type coercion.
### Enhanced Parameter Options
```elixir
tool "create_user", "Create a new user" do
# Basic types with constraints
param :name, :string, "Full name", required: true, min_length: 2, max_length: 50
param :age, :integer, "Age", min: 0, max: 150
param :email, :string, "Email address", validator: &ConduitMcp.Validation.Validators.email/1
# Enum validation
param :role, :string, "User role", enum: ["admin", "user", "guest"], default: "user"
# Numeric constraints
param :score, :number, "Performance score", min: 0.0, max: 100.0, default: 50.0
handle &UserService.create/2
end
```
### Type Coercion
Automatic type conversion when `type_coercion: true` (enabled by default):
```elixir
# Client sends: {"age": "25", "active": "true", "score": "85.5"}
# Server receives: %{"age" => 25, "active" => true, "score" => 85.5}
```
### Custom Validators
Use built-in validators or create your own:
```elixir
# Built-in validators
param :email, :string, "Email", validator: &ConduitMcp.Validation.Validators.email/1
param :url, :string, "Website", validator: &ConduitMcp.Validation.Validators.url/1
# Custom validator function
param :username, :string, "Username", validator: fn username ->
String.match?(username, ~r/^[a-zA-Z0-9_]{3,20}$/)
end
# Module function validator
param :priority, :string, "Priority", validator: {MyApp.Validators, :valid_priority?}
```
### Validation Configuration
Configure validation behavior in your application config:
```elixir
# config/config.exs
config :conduit_mcp, :validation,
runtime_validation: true, # Enable validation (default: true)
strict_mode: true, # Fail on validation errors (default: true)
type_coercion: true, # Auto-convert types (default: true)
log_validation_errors: false # Log failures (default: false)
```
### Error Responses
Validation failures return detailed error information:
```json
{
"error": {
"code": -32602,
"message": "Parameter validation failed",
"data": {
"errors": [
{
"parameter": "age",
"value": -5,
"message": "must be greater than or equal to 0"
},
{
"parameter": "email",
"value": "invalid-email",
"message": "must be a valid email address"
}
]
}
}
}
```
### Supported Constraints
| Constraint | Types | Description | Example |
|------------|-------|-------------|---------|
| `required: true` | All | Parameter must be present | `required: true` |
| `min: N` | number, integer | Minimum value (inclusive) | `min: 0` |
| `max: N` | number, integer | Maximum value (inclusive) | `max: 100` |
| `min_length: N` | string | Minimum string length | `min_length: 3` |
| `max_length: N` | string | Maximum string length | `max_length: 255` |
| `enum: [...]` | All | Value must be in list | `enum: ["red", "green", "blue"]` |
| `default: value` | All | Default if not provided | `default: "guest"` |
| `validator: fun` | All | Custom validation function | `validator: &valid_email?/1` |
## Authentication
Configure authentication in transport options:
```elixir
# No auth (development)
auth: [enabled: false]
# Static bearer token
auth: [
strategy: :bearer_token,
token: "your-secret-token"
]
# Static API key
auth: [
strategy: :api_key,
api_key: "your-api-key",
header: "x-api-key"
]
# Custom verification
auth: [
strategy: :function,
verify: fn token ->
case MyApp.Auth.verify(token) do
{:ok, user} -> {:ok, user}
_ -> {:error, "Invalid token"}
end
end
]
# Database lookup
auth: [
strategy: :function,
verify: fn token ->
case MyApp.Repo.get_by(ApiToken, token: token) do
%ApiToken{user: user} -> {:ok, user}
nil -> {:error, "Invalid token"}
end
end
]
```
Access authenticated user in tools:
```elixir
tool "profile", "Get profile" do
handle fn conn, _params ->
case conn.assigns[:current_user] do
nil -> error("Not authenticated")
user -> json(user)
end
end
end
```
## Rate Limiting
ConduitMCP supports two layers of rate limiting using [Hammer](https://hex.pm/packages/hammer). Both are optional — omit the config and no rate limiting is applied.
### Setup
Add `hammer` to your dependencies:
```elixir
def deps do
[
{:conduit_mcp, "~> 0.6.5"},
{:hammer, "~> 7.2"}
]
end
```
Define a Hammer module and add it to your supervision tree:
```elixir
defmodule MyApp.RateLimiter do
use Hammer, backend: :ets
end
# In your application.ex
children = [
{MyApp.RateLimiter, [clean_period: :timer.minutes(1)]}
]
```
### HTTP Rate Limiting
Limits raw HTTP connections — prevents DDoS and connection flooding.
```elixir
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
rate_limit: [
backend: MyApp.RateLimiter,
scale: :timer.seconds(60), # Time window (default: 60s)
limit: 100 # Max requests per window (default: 60)
]},
port: 4001}
```
| Option | Default | Description |
|--------|---------|-------------|
| `:backend` | required | Hammer module with `hit/3` |
| `:enabled` | `true` | Toggle on/off |
| `:scale` | `60_000` | Time window in ms |
| `:limit` | `60` | Max requests per window |
| `:key_func` | IP-based | `(Plug.Conn.t()) -> String.t()` |
### Message Rate Limiting
Limits MCP method calls (tool calls, resource reads, prompt gets) per time window. Think of it as: HTTP rate limit = "how fast can you knock on the door", message rate limit = "how many questions can you ask once inside."
```elixir
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
rate_limit: [backend: MyApp.RateLimiter, limit: 100, scale: 60_000],
message_rate_limit: [
backend: MyApp.RateLimiter,
scale: :timer.minutes(5), # Time window (default: 5 min)
limit: 50, # Max messages per window (default: 50)
excluded_methods: ["initialize", "ping"]
]},
port: 4001}
```
| Option | Default | Description |
|--------|---------|-------------|
| `:backend` | required | Hammer module with `hit/3` |
| `:enabled` | `true` | Toggle on/off |
| `:scale` | `300_000` | Time window in ms (5 min) |
| `:limit` | `50` | Max messages per window |
| `:key_func` | user-aware | Uses `conn.assigns[:current_user]` if set, falls back to IP |
| `:excluded_methods` | `[]` | Methods to skip (e.g., `["initialize", "ping"]`) |
Key behaviors:
- **POST only** — GET and OPTIONS requests pass through
- **Notifications skipped** — JSON-RPC notifications (no `id` field) are not counted
- **User-aware** — Default key uses authenticated user when Auth plug is in the pipeline
- **Key prefix** — Keys are prefixed with `"msg:"` to avoid collision with HTTP rate limiter
- **HTTP 429** — Returns JSON-RPC error with code `-32000` and `Retry-After` header
### Per-user Rate Limiting
```elixir
rate_limit: [
backend: MyApp.RateLimiter,
limit: 100,
key_func: fn conn ->
case conn.assigns[:current_user] do
%{id: id} -> "user:#{id}"
_ -> conn.remote_ip |> :inet.ntoa() |> to_string()
end
end
]
```
## Client Configuration
### VS Code / Cursor
```json
{
"mcpServers": {
"my-app": {
"url": "http://localhost:4001/",
"headers": {
"Authorization": "Bearer your-token"
}
}
}
}
```
### Claude Desktop
```json
{
"mcpServers": {
"my-app": {
"command": "elixir",
"args": ["/path/to/your/server.exs"]
}
}
}
```
## Telemetry
ConduitMCP emits telemetry events for monitoring:
- `[:conduit_mcp, :request, :stop]` - All MCP requests
- `[:conduit_mcp, :tool, :execute]` - Tool executions
- `[:conduit_mcp, :resource, :read]` - Resource reads
- `[:conduit_mcp, :prompt, :get]` - Prompt retrievals
- `[:conduit_mcp, :rate_limit, :check]` - HTTP rate limit checks
- `[:conduit_mcp, :message_rate_limit, :check]` - Message rate limit checks
- `[:conduit_mcp, :auth, :verify]` - Authentication attempts
Example handler:
```elixir
:telemetry.attach(
"mcp-logger",
[:conduit_mcp, :tool, :execute],
fn _event, %{duration: duration}, %{tool_name: name}, _config ->
ms = System.convert_time_unit(duration, :native, :millisecond)
Logger.info("Tool #{name} executed in #{ms}ms")
end,
nil
)
```
## Prometheus Metrics
ConduitMCP includes an optional PromEx plugin for Prometheus monitoring.
### Installation
Add `:prom_ex` to your dependencies:
```elixir
def deps do
[
{:conduit_mcp, "~> 0.6.5"},
{:prom_ex, "~> 1.11"}
]
end
```
### Setup
Add the ConduitMCP plugin to your PromEx configuration:
```elixir
defmodule MyApp.PromEx do
use PromEx, otp_app: :my_app
@impl true
def plugins do
[
PromEx.Plugins.Application,
PromEx.Plugins.Beam,
{ConduitMcp.PromEx, otp_app: :my_app}
]
end
@impl true
def dashboard_assigns do
[
datasource_id: "prometheus",
default_selected_interval: "30s"
]
end
end
```
Add to your supervision tree:
```elixir
def start(_type, _args) do
children = [
MyApp.PromEx,
# ... other children ...
]
Supervisor.start_link(children, strategy: :one_for_one)
end
```
### Metrics Available
All metrics are prefixed with `{otp_app}_conduit_mcp_`:
**Request Metrics:**
- `request_total{method, status}` - Total MCP requests
- `request_duration_milliseconds{method, status}` - Request duration distribution
**Tool Metrics:**
- `tool_execution_total{tool_name, status}` - Total tool executions
- `tool_duration_milliseconds{tool_name, status}` - Tool execution duration
**Resource Metrics:**
- `resource_read_total{status}` - Total resource reads
- `resource_read_duration_milliseconds{status}` - Read duration
**Prompt Metrics:**
- `prompt_get_total{prompt_name, status}` - Total prompt retrievals
- `prompt_get_duration_milliseconds{prompt_name, status}` - Retrieval duration
**Rate Limit Metrics:**
- `rate_limit_check_total{status}` - HTTP rate limit checks
- `rate_limit_check_duration_milliseconds` - Check duration
**Message Rate Limit Metrics:**
- `message_rate_limit_check_total{status, method}` - Message rate limit checks
- `message_rate_limit_check_duration_milliseconds{status, method}` - Check duration
**Auth Metrics:**
- `auth_verify_total{strategy, status}` - Total auth attempts
- `auth_verify_duration_milliseconds{strategy, status}` - Verification duration
### Example PromQL Queries
**Request rate by method:**
```promql
rate(myapp_conduit_mcp_request_total[5m])
```
**Error rate percentage:**
```promql
100 * (
rate(myapp_conduit_mcp_request_total{status="error"}[5m])
/
rate(myapp_conduit_mcp_request_total[5m])
)
```
**P95 tool execution duration:**
```promql
histogram_quantile(0.95,
rate(myapp_conduit_mcp_tool_duration_milliseconds_bucket[5m])
)
```
**Authentication success rate:**
```promql
100 * (
rate(myapp_conduit_mcp_auth_verify_total{status="ok"}[5m])
/
rate(myapp_conduit_mcp_auth_verify_total[5m])
)
```
See `ConduitMcp.PromEx` module documentation for complete details and alert examples.
## Documentation
- [API Documentation](https://hexdocs.pm/conduit_mcp)
- [Changelog](CHANGELOG.md)
- [MCP Specification](https://modelcontextprotocol.io/specification/)
## Examples
- [Simple Server Example](https://github.com/nyo16/conduit_mcp/tree/master/examples/simple_tools_server)
- [Phoenix Integration](https://github.com/nyo16/conduit_mcp/tree/master/examples/phoenix_mcp)
## License
Apache License 2.0