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.
- 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
def deps do
[
{:conduit_mcp, "~> 0.10"}
]
endRequires Elixir ~> 1.18.
The core library needs nothing beyond the block above. These features are compiled in only when their dependency is present, so add the ones you need before you configure the feature:
| Feature | Dependency | Add to deps |
|---|---|---|
auth: [strategy: :oauth] — JWT bearer validation (guide) |
:joken, :jose |
{:joken, "~> 2.6"}{:jose, "~> 1.11"} |
ConduitMcp.OAuth.KeyProvider.JWKS — fetch signing keys from a JWKS URI |
:req |
{:req, "~> 0.6.1 or ~> 0.7"} |
ConduitMcp.Plugs.RateLimit / ConduitMcp.Plugs.MessageRateLimit (guide) |
:hammer |
{:hammer, "~> 7.2"} |
ConduitMcp.PromEx — Prometheus metrics |
:prom_ex |
{:prom_ex, "~> 1.11"} |
Adding one of these later requires a forced rebuild. These modules are guarded by
if Code.ensure_loaded?(Dep), which is evaluated once — when:conduit_mcpis compiled inside your project's_build. Mix does not recompile an already-built dependency when you add a new one, so run:mix deps.get mix deps.compile conduit_mcp --forceSkipping this leaves the feature absent. ConduitMCP detects that at
init/1and raisesConduitMcp.OptionalDependencyErrornaming the missing dependency and this command, rather than failing at request time.
Define a server in one module:
defmodule MyServer do
use ConduitMcp.Server
tool "echo", "Echo a message" do
param :message, :string, "Message to echo", required: true
handle fn _conn, %{"message" => msg} -> text(msg) end
end
endRun it with Bandit:
# lib/my_app/application.ex
children = [
{Bandit, plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyServer}, port: 4000}
]Try it:
curl -s -X POST http://localhost:4000/ -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "echo", "arguments": {"message": "hello"}}
}'For multi-tool servers, authentication, sessions, OAuth, MCP Apps, and more, see the guides and examples.
ConduitMCP is pre-1.0. Most APIs are stable; the following are explicitly experimental and may change before 1.0:
ConduitMcp.TasksandConduitMcp.Tasks.Janitor— task lifecycle is new in MCP spec 2025-11-25 and the surface area may evolve.ConduitMcp.Client— message builders for server-to-client requests (sampling, elicitation, roots). The bidirectional transport these depend on is not yet wired through Streamable HTTP.ConduitMcp.Cancellation— the cooperative cancellation model may grow a more direct preemption path.
Breaking changes between minor versions will be called out in CHANGELOG.md.
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 |
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.
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
endEach 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" => JSON.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.
# 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# 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")]
end| 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.
All tool/resource/prompt handlers return {:ok, map()} or {:error, map()}. Helper macros are imported automatically in DSL and Endpoint modes.
| 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 |
| 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" => ...}} |
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"}, ...]}}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.
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 |
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.
| 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 |
additional_properties: bool |
object | additional_properties: true |
An :object param declared with a block validates its nested fields — required,
types, and the constraints above — to any depth, and rejects undeclared keys:
param :user, :object, "User data", required: true do
field :name, :string, "Full name", required: true
field :address, :object, "Address" do
field :city, :string, "City", required: true
end
endDeclared with no block it is an open bag: any keys accepted, nothing enforced.
additional_properties: true combines the two — declared fields are enforced
and undeclared keys pass through to the handler. The generated JSON Schema
always emits additionalProperties, so it agrees with what the server enforces.
Array item types are declared with items:
param :rows, :array, "Rows" do
items :object do
field :id, :integer, "Row id", required: true
end
endItem schemas are published for clients but not enforced server-side — NimbleOptions cannot attach a nested schema to a list element type.
Handlers always receive string keys, at every depth.
Enabled by default. Automatic conversion: "25" → 25, "true" → true, "85.5" → 85.5.
config :conduit_mcp, :validation,
runtime_validation: true,
strict_mode: true,
type_coercion: true,
log_validation_errors: falseConfigure 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.
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.
StreamableHTTP supports server-side sessions with pluggable stores. Sessions are off unless you configure them:
session: [] # Opt in, using ConduitMcp.Session.EtsStore
session: [store: MyApp.RedisSessionStore] # Opt in with a custom store
session: false # Off — same as omitting :sessionAny other :session value (true, a map) raises ArgumentError at the
transport's init/1, so the mistake fails at boot.
See guides: Multi-Node Sessions
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.
{
"mcpServers": {
"my-app": {
"url": "http://localhost:4001/",
"headers": {
"Authorization": "Bearer your-token"
}
}
}
}{
"mcpServers": {
"my-app": {
"command": "elixir",
"args": ["/path/to/your/server.exs"]
}
}
}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 |
- 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
Apache License 2.0