
ConduitMCP
An Elixir implementation of the Model Context Protocol (MCP) specification (2025-11-25). Build MCP servers to expose tools, resources, and prompts to LLM applications like Claude Desktop, VS Code, and Cursor.
Features
- MCP Apps — Tools can return interactive UI rendered as sandboxed iframes in host clients
- Three Ways to Build — DSL macros, raw callbacks, or component modules — pick your level of control
- Full MCP Spec — Tools, resources, prompts, completion, logging, subscriptions (MCP 2025-11-25 + 2025-06-18)
- Runtime Validation — NimbleOptions-powered param validation with type coercion and custom constraints
- Stateless Architecture — Pure functions, no processes, maximum concurrency via Bandit
- Authentication — Bearer tokens, API keys, OAuth 2.1 (RFC 9728), custom verification
- Rate Limiting — HTTP-level and message-level rate limiting with Hammer
- Session Management — Pluggable session stores (ETS, Redis, PostgreSQL, Mnesia)
- Observability — Telemetry events, optional Prometheus metrics via PromEx
- Phoenix Ready — Drop-in integration with Phoenix routers
- CORS & Security — Configurable origins, preflight handling, origin validation
Installation
def deps do
[
{:conduit_mcp, "~> 0.9.0"}
]
endRequires Elixir ~> 1.18.
Three Ways to Define Servers
ConduitMCP gives you three modes. Each is a complete, independent way to build an MCP server — pick whichever fits your project.
| DSL Mode | Manual Mode | Endpoint Mode | |
|---|---|---|---|
| Style | Declarative macros | Raw callbacks | Component modules |
| Schema | Auto-generated | You build the maps | Auto from schema do field ... end |
| Params | String-keyed maps | String-keyed maps | Atom-keyed maps |
| Rate limiting | Transport option | Transport option | Declarative in use opts |
| Best for | Quick setup | Maximum control | Larger servers, team projects |
1. DSL Mode
Everything in one module with compile-time macros. Schemas and validation generated automatically.
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
endResponse helpers (auto-imported): text/1, json/1, image/1, audio/2, error/1, raw/1, raw_resource/2, system/1, user/1, assistant/1 — see Responses for details and custom response patterns.
2. Manual Mode
Full control. You implement callbacks directly with raw JSON Schema maps. No compile-time magic.
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}}
@impl true
def handle_call_tool(_conn, "greet", %{"name" => name}) do
{:ok, %{"content" => [%{"type" => "text", "text" => "Hello, #{name}!"}]}}
end
end3. Endpoint + Component Mode
Each tool, resource, or prompt is its own module. An Endpoint aggregates them with declarative config for rate limiting, auth, and server metadata.
# Each tool is its own module
defmodule MyApp.Echo do
use ConduitMcp.Component, type: :tool, description: "Echoes text back"
schema do
field :text, :string, "The text to echo", required: true, max_length: 500
end
@impl true
def execute(%{text: text}, _conn) do
text(text)
end
end
defmodule MyApp.ReadUser do
use ConduitMcp.Component,
type: :resource,
uri: "user://{id}",
description: "User by ID",
mime_type: "application/json"
@impl true
def execute(%{id: id}, _conn) do
user = MyApp.Users.get!(id)
{:ok, %{"contents" => [%{
"uri" => "user://#{id}",
"mimeType" => "application/json",
"text" => Jason.encode!(user)
}]}}
end
end
# Endpoint aggregates components
defmodule MyApp.MCPServer do
use ConduitMcp.Endpoint,
name: "My App",
version: "1.0.0",
rate_limit: [backend: MyApp.RateLimiter, limit: 60, scale: 60_000],
message_rate_limit: [backend: MyApp.RateLimiter, limit: 50, scale: 300_000]
component MyApp.Echo
component MyApp.ReadUser
endEndpoint config is auto-extracted by transports — no duplication needed:
{Bandit,
plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
port: 4001}See the Endpoint Mode Guide for full details on components, schema DSL, and options.
Running Your Server
Standalone with Bandit
# 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)
endPhoenix Integration
# lib/my_app_web/router.ex
scope "/mcp" do
forward "/", ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
auth: [strategy: :bearer_token, token: System.get_env("MCP_AUTH_TOKEN")]
endTransports
| Transport | Module | Description |
|---|---|---|
| StreamableHTTP | ConduitMcp.Transport.StreamableHTTP | Recommended. Single POST / endpoint for bidirectional communication |
| SSE | ConduitMcp.Transport.SSE | Legacy. GET /sse for streaming, POST /message for requests |
Both transports support authentication, rate limiting, CORS, and session management.
Responses
All tool/resource/prompt handlers return {:ok, map()} or {:error, map()}. Helper macros are imported automatically in DSL and Endpoint modes.
Tool Response Helpers
| Helper | What it returns | Use case |
|---|---|---|
text("hello") | {:ok, %{"content" => [%{"type" => "text", "text" => "hello"}]}} | Plain text responses |
json(%{a: 1}) | {:ok, %{"content" => [%{"type" => "text", "text" => "{\"a\":1}"}]}} | Structured data (Jason-encoded) |
image(base64_data) | {:ok, %{"content" => [%{"type" => "image", "data" => ...}]}} | Images (base64) |
audio(data, "audio/wav") | {:ok, %{"content" => [%{"type" => "audio", "data" => ..., "mimeType" => ...}]}} | Audio clips |
error("fail") | {:error, %{"code" => -32000, "message" => "fail"}} | Error with default code |
error("fail", -32602) | {:error, %{"code" => -32602, "message" => "fail"}} | Error with custom code |
raw(any_map) | {:ok, any_map} | Bypass MCP wrapping entirely |
raw_resource(html, "text/html") | {:ok, %{"contents" => [%{"mimeType" => ..., "text" => ...}]}} | Resource content with MIME type |
Prompt Message Helpers
| Helper | Returns |
|---|---|
system("You are a reviewer") | %{"role" => "system", "content" => %{"type" => "text", "text" => ...}} |
user("Review this code") | %{"role" => "user", "content" => %{"type" => "text", "text" => ...}} |
assistant("Here is my review") | %{"role" => "assistant", "content" => %{"type" => "text", "text" => ...}} |
Multi-Content Responses
Use texts/1 to return multiple text items in a single response:
{:ok, %{"content" => texts(["Line 1", "Line 2", "Line 3"])}}
# => {:ok, %{"content" => [%{"type" => "text", "text" => "Line 1"}, ...]}}Raw / Fully Custom Responses
For maximum control, skip the helpers entirely and return the map yourself:
def execute(_params, _conn) do
{:ok, %{
"content" => [
%{"type" => "text", "text" => "Here is the chart:"},
%{"type" => "image", "data" => base64_png, "mimeType" => "image/png"},
%{"type" => "text", "text" => "Analysis complete."}
]
}}
endThe raw/1 helper is a shortcut for returning any map without MCP content wrapping — useful for debugging or non-standard responses:
raw(%{"custom_key" => "custom_value", "nested" => %{"data" => [1, 2, 3]}})
# => {:ok, %{"custom_key" => "custom_value", "nested" => %{"data" => [1, 2, 3]}}}Note:
raw/1bypasses the MCP content structure. Clients expecting standard"content"arrays won't parse it correctly. Use it for debugging or custom integrations.
Error Codes
Standard JSON-RPC 2.0 error codes used by the protocol:
| Code | Meaning |
|---|---|
-32700 | Parse error |
-32600 | Invalid request |
-32601 | Method not found |
-32602 | Invalid params |
-32603 | Internal error |
-32000 | Tool/server error (default for error/1) |
-32002 | Resource not found |
Parameter Validation
All three modes support runtime validation via NimbleOptions. DSL and Endpoint modes generate validation schemas automatically. Manual mode can opt in via __validation_schema_for_tool__/1.
Constraints
| Constraint | Types | Example |
|---|---|---|
required: true | All | required: true |
min: N / max: N | number, integer | min: 0, max: 100 |
min_length: N / max_length: N | string | min_length: 3, max_length: 255 |
enum: [...] | All | enum: ["red", "green", "blue"] |
default: value | All | default: "guest" |
validator: fun | All | validator: &valid_email?/1 |
Type Coercion
Enabled by default. Automatic conversion: "25" → 25, "true" → true, "85.5" → 85.5.
Configuration
config :conduit_mcp, :validation,
runtime_validation: true,
strict_mode: true,
type_coercion: true,
log_validation_errors: falseAuthentication
Configure in transport options or Endpoint use opts:
# Bearer token
auth: [strategy: :bearer_token, token: "your-secret-token"]
# API key
auth: [strategy: :api_key, api_key: "your-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]
# OAuth 2.1 (RFC 9728)
auth: [strategy: :oauth, issuer: "https://auth.example.com", audience: "my-app"]Authenticated user is available via conn.assigns[:current_user] in all callbacks.
Rate Limiting
Two layers using Hammer (optional dependency):
# Setup: add {:hammer, "~> 7.2"} to deps, then:
defmodule MyApp.RateLimiter do
use Hammer, backend: :ets
endHTTP rate limiting — limits raw connections:
rate_limit: [backend: MyApp.RateLimiter, limit: 100, scale: 60_000]Message rate limiting — limits MCP method calls (tool calls, reads, prompts):
message_rate_limit: [
backend: MyApp.RateLimiter,
limit: 50,
scale: 300_000,
excluded_methods: ["initialize", "ping"]
]Both support per-user keying via :key_func. Returns HTTP 429 with Retry-After header.
Session Management
StreamableHTTP supports server-side sessions with pluggable stores:
session: [store: ConduitMcp.Session.EtsStore] # Default
session: [store: MyApp.RedisSessionStore] # Custom store
session: false # DisableSee guides: Multi-Node Sessions
Telemetry
Events emitted for monitoring:
| Event | Description |
|---|---|
[: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 |
Optional Prometheus metrics via ConduitMcp.PromEx — see module docs.
Client Configuration
VS Code / Cursor
{
"mcpServers": {
"my-app": {
"url": "http://localhost:4001/",
"headers": {
"Authorization": "Bearer your-token"
}
}
}
}Claude Desktop
{
"mcpServers": {
"my-app": {
"command": "elixir",
"args": ["/path/to/your/server.exs"]
}
}
}MCP Spec Coverage
ConduitMCP implements the full MCP specification:
| Feature | Status | Spec Version |
|---|---|---|
| Tools (list, call) | Supported | 2025-06-18 |
| Resources (list, read, subscribe) | Supported | 2025-06-18 |
| Prompts (list, get) | Supported | 2025-06-18 |
| Completion | Supported | 2025-06-18 |
| Logging | Supported | 2025-06-18 |
| Protocol negotiation | Supported | 2025-11-25 |
| Session management | Supported | 2025-11-25 |
| OAuth 2.1 (RFC 9728) | Supported | 2025-11-25 |
| StreamableHTTP transport | Supported | 2025-11-25 |
| SSE transport (legacy) | Supported | 2025-06-18 |
| MCP Apps (ext-apps) | Supported | Extension |
Guides
- Choosing a Mode — DSL vs Manual vs Endpoint comparison
- Endpoint Mode — Component modules, schema DSL, full walkthrough
- Authentication — All auth strategies in detail
- Rate Limiting — HTTP and message rate limiting
- Multi-Node Sessions — Redis, PostgreSQL, Mnesia session stores
- Oban Tasks — Long-running tasks with Oban
- MCP Apps — Interactive UI from MCP tools
Documentation
Examples
License
Apache License 2.0