A Model Context Protocol (MCP) server library
for Elixir, implementing the 2025-11-25
specification over the Streamable HTTP transport.
- Define servers with a concise DSL or the full
Urchin.Serverbehaviour. - Mount as a
Pluginto Phoenix/Plug pipelines, or run standalone with Bandit. - Tools, resources, resource templates, prompts, completion and logging.
- Server-initiated requests over SSE: sampling, elicitation and roots.
- Progress notifications, cancellation, pagination and resumable SSE streams.
- Optional OAuth 2.1 authorization: RFC 9728 discovery and pluggable token validation.
This library implements the server side only. The stdio transport is intentionally not supported; only Streamable HTTP is provided.
- Elixir
~> 1.18(verified on 1.18 and 1.19) - Erlang/OTP 25+ (verified on OTP 25, 27 and 28)
Add urchin to your dependencies:
def deps do
[
{:urchin, "~> 0.1"},
# Required only for the standalone endpoint (Urchin.start_link / Urchin.Endpoint):
{:bandit, "~> 1.6"}
]
endDefine a server with the DSL:
defmodule Demo.Server do
use Urchin.Server, name: "demo", version: "1.0.0", instructions: "A demo MCP server."
tool "echo",
description: "Echo the message back",
input_schema: %{
"type" => "object",
"properties" => %{"message" => %{"type" => "string"}},
"required" => ["message"]
} do
{:ok, [Urchin.Content.text(args["message"])]}
end
tool "add",
description: "Add two integers",
input_schema: %{
"type" => "object",
"properties" => %{"a" => %{"type" => "integer"}, "b" => %{"type" => "integer"}},
"required" => ["a", "b"]
},
output_schema: %{"type" => "object", "properties" => %{"sum" => %{"type" => "integer"}}} do
sum = args["a"] + args["b"]
{:ok, [Urchin.Content.text(Integer.to_string(sum))], structured_content: %{"sum" => sum}}
end
endRun it standalone (requires :bandit):
{:ok, _pid} = Urchin.start_link(Demo.Server, port: 4000, path: "/mcp")or supervise it:
children = [{Urchin.Endpoint, server: Demo.Server, port: 4000, path: "/mcp"}]
Supervisor.start_link(children, strategy: :one_for_one)The endpoint now speaks Streamable HTTP at http://127.0.0.1:4000/mcp.
The transport is a plain Plug. Mount it before any body parser, since it reads the
raw request body itself:
# Phoenix router
forward "/mcp", Urchin.Transport.StreamableHTTP, server: Demo.Server# Plug.Router
forward "/mcp", to: Urchin.Transport.StreamableHTTP, init_opts: [server: Demo.Server]defmodule Demo.Server do
use Urchin.Server, name: "demo", version: "1.0.0"
resource "config://app", name: "app-config", mime_type: "application/json" do
{:ok, [Urchin.Content.text_resource(ctx.uri, ~s({"ok": true}), mime_type: "application/json")]}
end
# RFC 6570 template; ctx.params holds the extracted variables.
resource_template "files://{path}", name: "files" do
{:ok, [Urchin.Content.text_resource(ctx.uri, File.read!(ctx.params["path"]))]}
end
prompt "greet",
description: "A greeting prompt",
arguments: [%{name: "name", required: true}] do
{:ok, [Urchin.Prompt.user_message(Urchin.Content.text("Hello " <> args["name"]))], "A greeting"}
end
endInside a tool/prompt block, args (the decoded arguments) and ctx
(an Urchin.Context) are bound. Inside a resource/resource_template block, only
ctx is bound; for templates ctx.uri and ctx.params are set.
Urchin.Content builds the content blocks used in tool results and prompt messages:
Urchin.Content.text("hello")
Urchin.Content.image(base64_png, "image/png")
Urchin.Content.audio(base64_wav, "audio/wav")
Urchin.Content.resource_link(%Urchin.Resource{uri: "file://a", name: "a"})
Urchin.Content.embedded(Urchin.Content.text_resource("file://a", "data"))and the resource contents returned from resources/read:
Urchin.Content.text_resource("file://a", "data", mime_type: "text/plain")
Urchin.Content.blob_resource("file://a", Base.encode64(bytes), mime_type: "application/octet-stream")Handlers receive an Urchin.Context that can stream notifications related to the
in-flight request. Emitting anything before the result automatically upgrades the
HTTP response to an SSE stream.
tool "import", description: "Long running import" do
Urchin.Context.progress(ctx, 25, total: 100, message: "reading")
Urchin.Context.log(ctx, "info", "import started")
if Urchin.Context.cancelled?(ctx) do
{:error, "cancelled"}
else
{:ok, [Urchin.Content.text("done")]}
end
endProgress notifications are only sent when the client supplied a progressToken.
A handler can call back into the client and await the response. These travel on the SSE stream of the originating request; the client's reply arrives on a later POST and is correlated automatically.
tool "summarize", description: "Summarize via the client's LLM" do
{:ok, result} =
Urchin.Context.create_message(ctx, %{
messages: [%{role: "user", content: Urchin.Content.text("Summarize: " <> args["text"])}],
maxTokens: 200
})
{:ok, [Urchin.Content.text(result["content"]["text"])]}
end
tool "ask_name", description: "Ask the user for their name" do
case Urchin.Context.elicit(ctx, %{
message: "What is your name?",
requestedSchema: %{
"type" => "object",
"properties" => %{"name" => %{"type" => "string"}},
"required" => ["name"]
}
}) do
{:ok, %{"action" => "accept", "content" => %{"name" => name}}} ->
{:ok, [Urchin.Content.text("Hello " <> name)]}
{:ok, _} ->
{:ok, [Urchin.Content.text("No name provided")]}
end
endUrchin.Context.list_roots/2 is also available.
Authorization is optional and off by default. When enabled, Urchin acts as an OAuth 2.1 Resource Server: it validates inbound bearer tokens and advertises its authorization server through RFC 9728 Protected Resource Metadata. The authorization server itself (token/authorization endpoints, PKCE, consent) is external and out of scope.
Configure it with Urchin.Auth.new!/1. The :token_validator is the pluggable seam where
you verify the token's signature/expiry/issuer (with your JWT or introspection library of
choice) and return Urchin.Auth.Claims:
defmodule Demo.Tokens do
@behaviour Urchin.Auth.TokenValidator
@impl true
def validate(token, _auth) do
case verify_jwt(token) do
{:ok, payload} -> {:ok, Urchin.Auth.Claims.from_map(payload)}
:error -> {:error, :invalid_token}
end
end
end
auth =
Urchin.Auth.new!(
# canonical server URI; also the expected token audience (RFC 8707)
resource: "https://mcp.example.com/mcp",
authorization_servers: ["https://auth.example.com"],
scopes_supported: ["mcp:tools", "files:read", "files:write"],
token_validator: Demo.Tokens
)The standalone runner serves the discovery document for you, at
https://mcp.example.com/.well-known/oauth-protected-resource/mcp:
{:ok, _pid} = Urchin.start_link(Demo.Server, port: 4000, path: "/mcp", auth: auth)When mounting the transport yourself, add Urchin.Auth.Metadata (serves discovery at the
host root) and either pass :auth to the transport or use Urchin.Auth.Plug:
# Plug.Router
plug Urchin.Auth.Metadata, auth: auth
forward "/mcp", to: Urchin.Transport.StreamableHTTP, init_opts: [server: Demo.Server, auth: auth]Unauthenticated requests get a 401 with a WWW-Authenticate: Bearer ..., resource_metadata="..."
challenge so clients can discover the authorization server; under-scoped tokens get a 403
insufficient_scope. The validated claims are available to handlers as ctx.auth for
per-tool decisions:
tool "delete", description: "Delete a file" do
if Urchin.Auth.Claims.has_scope?(Urchin.Context.auth(ctx), "files:write") do
{:ok, [Urchin.Content.text("deleted")]}
else
{:error, "files:write scope required"}
end
endSee Urchin.Auth for the full option list (audience validation, required_scopes, extra
metadata fields).
For stateful servers or full control, implement Urchin.Server directly. All callbacks
except c:Urchin.Server.server_info/0 are optional, and a feature is supported only when
its callbacks exist.
defmodule Custom.Server do
@behaviour Urchin.Server
@impl true
def server_info, do: %{name: "custom", version: "1.0.0"}
@impl true
def capabilities, do: Urchin.Capabilities.server(%{tools: %{}})
@impl true
def init(_arg), do: {:ok, %{started_at: System.system_time()}}
@impl true
def list_tools(_cursor, _ctx), do: {:ok, [Urchin.Tool.new(name: "ping")]}
@impl true
def call_tool("ping", _args, ctx) do
{:ok, [Urchin.Content.text("pong; state=#{inspect(Urchin.Context.state(ctx))}")]}
end
endThe DSL and the behaviour may be mixed: declare some features with the DSL and
hand-write the callbacks for others. State returned by init/1 is available via
Urchin.Context.state/1.
Passed to Urchin.Transport.StreamableHTTP, Urchin.Endpoint or Urchin.start_link/2:
| Option | Default | Description |
|---|---|---|
:server |
(required) | the Urchin.Server module |
:init_arg |
nil |
argument passed to init/1 once per session |
:allowed_origins |
nil |
:all, a list of allowed Origins, or nil for localhost only |
:require_session |
true |
reject post-initialize requests without a session id |
:enable_get |
true |
offer the GET SSE stream (else 405) |
:allow_delete |
true |
allow client session termination via DELETE (else 405) |
:min_log_level |
"info" |
default minimum log level for new sessions |
:request_timeout |
60_000 |
per-request handler timeout (ms) |
:validate_protocol_version |
true |
validate the MCP-Protocol-Version header |
:auth |
nil |
an Urchin.Auth (or keyword options) to require OAuth 2.1 bearer tokens; nil disables authorization |
Urchin.Endpoint/Urchin.start_link/2 additionally accept :port, :ip, :scheme and :path.
| Area | Methods |
|---|---|
| Lifecycle | initialize, notifications/initialized, version negotiation |
| Tools | tools/list, tools/call, notifications/tools/list_changed |
| Resources | resources/list, resources/templates/list, resources/read, resources/subscribe, resources/unsubscribe, notifications/resources/updated, notifications/resources/list_changed |
| Prompts | prompts/list, prompts/get, notifications/prompts/list_changed |
| Completion | completion/complete |
| Logging | logging/setLevel, notifications/message |
| Utilities | ping, notifications/cancelled, notifications/progress, pagination |
| Server → client | sampling/createMessage, elicitation/create, roots/list |
| Authorization | OAuth 2.1 resource server: RFC 9728 metadata discovery, WWW-Authenticate challenges, RFC 8707 audience binding (optional) |
The transport implements: a single endpoint serving POST/GET/DELETE, the
JSON-vs-SSE response decision, 202 Accepted for notifications and responses,
Origin validation, MCP-Session-Id management, the MCP-Protocol-Version header,
SSE priming events, per-stream event ids, and Last-Event-ID resumption of the GET
stream.
- The stdio transport (out of scope by design).
- The OAuth 2.1 authorization server: Urchin is the resource server only. Token, authorization and registration endpoints, PKCE and consent live in an external authorization server.
- Task-augmented execution (
tasks/*) is not yet implemented; servers advertise notaskscapability.
When exposing a server beyond localhost, configure :allowed_origins, bind to the
intended interface via :ip, and require authorization with :auth (see
Authorization). The transport validates the Origin header
(DNS-rebinding protection) and issues cryptographically random session ids by default.
MIT