An OTP-native Elixir client and server SDK for the Model Context Protocol. The
unreleased 2.0 line supports stateful 2025-11-25 plus
stateless 2026-07-28 over stdio/in-process and Streamable HTTP transports.
2.0.0-rc.1is a release candidate. Its handler API is a breaking cutover from 1.x, while its wire protocol remains compatible with2025-11-25peers.
- Dual-version selection: clients prefer
2026-07-28, then perform bounded fallback to2025-11-25only for lifecycle/version incompatibility signals. - Version-isolated lifecycles: 2026 requests use
server/discoverand per-request metadata; 2025 requests use initialize/initialized and a session. - OTP ownership: clients and stdio connections are GenServers; long-lived subscription workers run under consumer-supplied supervisors.
- Tools, resources, prompts, completions, extensions, and MRTR input-required round trips.
- Stable MCP Apps (
2026-01-26) metadata/resource validation, immutable App definitions, exact-client resolution, and pure View/host bridge codecs. - Draft SEP-2640 Skills extension contracts and opt-in runtime support for
skills/list,skills/get, and gatedresources/directory/read. The implementation is pinned to SEP PR head753b9f2be43e07fdd070e535d75f190cff14beea(reviewed 2026-08-24); it is an extension-track feature, not part of the SDK's core-conformance denominator. - Streamable HTTP routing headers, including schema-directed
Mcp-Param-*headers and a bounded schema index. - Lossless JSON Schema 2020-12 maps and full JSON structured-content values.
- Bounded request deadlines, isolated resolver/notification callbacks, and no client-side result cache.
- Configurable HTTP and stdio security policies with gateway-hardened presets, direction-specific body/frame limits, redirects disabled, and process-tree cleanup.
- Server handler callbacks have a shared 30-second default deadline and a 32-callback admission limit across owner-based and Streamable HTTP transports.
The authoritative design package is in docs/sdk-2.0. The
implementation tracks the pinned official schema revision documented in
docs/adr/0003-2.0.0-conformance-scope.md.
No production installation coordinate is currently advertised. The package
metadata is prepared for v2.0.0-rc.1, but that tag and Hex release do not
exist until the branch-finishing and publication workflows complete. Evaluation
may use this immutable reviewed PR snapshot (it is not a release tag or the
current main commit):
def deps do
[
{:mcp_elixir_sdk,
git: "https://github.com/jmagar/mcp-elixir-sdk.git",
ref: "e9c7fb8927de1d54c74ffecd21bfed63ba1a19ef"}
]
endNo published production coordinate is currently advertised.
Streamable HTTP uses the optional Req, Plug, and Bandit dependencies. Req
is supported across >= 0.6.1 and < 0.8.0. Req 0.6.1 is the security floor;
earlier releases are affected by CVE-2026-49755.
Platform support: Unix only. erlexec, which supervises stdio subprocesses in
process groups, is a required dependency whose NIF does not build on Windows, so
the package does not compile there.
Subprocess trust boundary. Explicit close, protocol-violation shutdown, and natural root exit terminate the process group and sweep Linux descendants that inherit the wrapper's per-launch cleanup marker, including descendants that create a new session. A hostile program can deliberately discard inherited identity before spawning. Gateways running untrusted commands must therefore add an OS containment boundary such as a cgroup or sandbox.
{:ok, client} =
MCP.Client.start_link(
transport:
{MCP.Transport.StreamableHTTP.Client,
url: "http://127.0.0.1:4000/mcp"},
client_info: %{name: "my_client", version: "1.0.0"},
client_capabilities: %{"elicitation" => %{}},
on_input_required: fn requests ->
Map.new(requests, fn {id, _request} ->
{id, %{"action" => "accept", "content" => %{"approved" => true}}}
end)
end
)
{:ok, discovery} = MCP.Client.connect(client)
{:ok, %{"tools" => tools}} = MCP.Client.list_tools(client)
{:ok, result} = MCP.Client.call_tool(client, "add", %{"a" => 20, "b" => 22})
:ok = MCP.Client.close(client)connect/2 prefers server/discover. If the peer requires a legacy lifecycle,
it tries 2025-11-25, initializes a session, and sends
notifications/initialized. Pass that legacy version through
protocol_version: to start directly in that mode.
Each operation has one end-to-end deadline covering transport work, schema
refresh, and any MRTR resolver invocation. Cache hints are returned to the
caller but results are never cached by the SDK.
MCP Apps support is opt-in and targets stable SEP-1865. Enable the extension on
a client with client_capabilities: MCP.Apps.client_capabilities(), then resolve
the UI resource directly from a listed tool descriptor:
{:ok, %{"tools" => [tool | _]}} = MCP.Client.list_tools(client)
{:ok, app} = MCP.Apps.Client.resolve(client, tool)
{:text, html} = app.contentServer authors can validate a linked tool/resource/content triple with
MCP.Apps.AppDefinition.new/4 and compose the resulting ordinary maps into
their existing handler callbacks. The canonical MIME type is
text/html;profile=mcp-app; linked resources use ui:// URIs and
_meta.ui.resourceUri.
The SDK does not render or execute html. An embedding host owns iframe
sandboxing, postMessage delivery, consent, effective CSP/Permission Policy,
and model/app visibility enforcement. MCP.Apps.Bridge provides bounded codecs
and a pure lifecycle transition validator for that host. A remote MCP server
cannot infer iframe origin from _meta, arguments, or custom headers.
ui/open-link accepts only absolute HTTPS URLs by default, with a non-empty
host and no userinfo. An embedding host may explicitly add trusted schemes with
open_link_schemes:, but decoding success validates only that wire policy; it
does not grant consent or authorize navigation. Executable, inline-content,
local-file, relative, and unconfigured custom-scheme URLs are rejected as
:invalid_bridge_params.
For stdio diagnostics, set transport
security_policy: [stderr: :capture] and provide the client an explicit
stderr_handler: pid or one-argument function. Captured data is bounded by the
stdio policy and is never logged automatically. Cleanup failures are available
through MCP.Client.transport_failure/1; pending operations fail with
{:transport_closed, {:cleanup_failed, cleanup_reason, close_reason}}.
Subscriptions are explicit processes, not lazy enumerables:
{:ok, subscription_supervisor} =
DynamicSupervisor.start_link(strategy: :one_for_one)
{:ok, client} =
MCP.Client.start_link(
transport: {MyTransport, []},
subscription_supervisor: subscription_supervisor
)
filter = %MCP.Protocol.Types.SubscriptionFilter{tools_list_changed: true}
{:ok, handle} = MCP.Client.listen_subscriptions(client, filter)
{:ok, acknowledgment} = MCP.Client.SubscriptionHandle.next(handle, 5_000)
case MCP.Client.SubscriptionHandle.close(handle) do
:ok -> :closed
{:error, {:close_failed, reason}} -> {:cleanup_unconfirmed, reason}
endEach worker has a bounded FIFO queue (256 by default). Overflow, owner death,
or transport loss closes only that subscription.
Closing an already-closed handle is idempotently successful. A timeout or
abnormal worker exit returns {:error, {:close_failed, reason}}; that error does
not prove the remote subscription was cancelled. Pass a second timeout argument
to close/2 when the five-second default is unsuitable.
Handler configuration is immutable after init/1. Put mutable state behind a
supervised process and store its pid or registered name in the launch config.
Every request callback receives MCP.Server.ToolContext before that config.
MCP.Server.Config.build/2 reports every initialization failure as
{:error, {:handler_init_failed, detail}}. A normal {:error, reason} return
uses reason as the detail; raises, throws, exits, and malformed returns use
{:raised, exception}, {:thrown, value}, {:exited, reason}, and
{:invalid_return, value} respectively. Consumers may safely match the outer
tag without parsing exception text.
defmodule MyApp.MCPHandler do
@behaviour MCP.Server.Handler
alias MCP.Server.ToolContext
@impl true
def init(opts), do: {:ok, %{store: Keyword.fetch!(opts, :store)}}
@impl true
def handle_list_tools(_cursor, %ToolContext{}, _config) do
{:ok,
[
%{
"name" => "add",
"description" => "Adds two numbers",
"inputSchema" => %{
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"type" => "object",
"properties" => %{
"a" => %{"type" => "number"},
"b" => %{"type" => "number"}
},
"required" => ["a", "b"]
}
}
], nil}
end
@impl true
def handle_call_tool("add", %{"a" => a, "b" => b}, %ToolContext{}, _config) do
{:ok, [%{"type" => "text", "text" => to_string(a + b)}]}
end
def handle_call_tool(name, _arguments, %ToolContext{}, _config) do
{:error, -32_601, "Unknown tool: #{name}"}
end
endThe callback forms are documented in MCP.Server.Handler. A handler may
return {:input_required, requests_map, request_state} from handle_call_tool/4;
the client resolves the requests and retries with a new JSON-RPC id.
The complete quick-start handler is compiled
and exercised by the test suite.
Callback families are atomic. Tools require both list and call callbacks,
resources require both list and read callbacks, and prompts require both list
and get callbacks. MCP.Server.Config.build/2 rejects a partial family with
{:error, {:invalid_callback_family, family, missing_callback}} instead of
advertising a capability that cannot be served.
All ordinary handler callbacks run beneath the SDK callback supervisor with a
finite deadline and shared admission limit. Configure these through
server_opts as handler_callback_timeout: (30,000 ms by default) and
max_concurrent_handler_callbacks: (32 by default). A deadline produces the
stable internal-error message handler callback timed out; exhausted admission
produces handler capacity reached. Timed-out tasks are terminated before their
capacity is released, and subsequent requests remain serviceable.
Skills support transports catalogs and resource bytes; it does not execute
instructions, approve skills, grant tools, or turn remote resources into local
trusted files. frontmatter, Markdown, scripts, _meta, and allowed-tools
remain untrusted server data. A digest match proves consistency with the
server-supplied manifest, not trust or authorization.
Hosts remain responsible for preserving the originating connection together with every skill URI, displaying that origin, obtaining explicit per-skill consent, binding persisted approval to the complete static manifest, revoking approval when it changes, isolating caches by origin, verifying size/digest and frontmatter, rejecting unlisted reads while acting on a static skill, and requiring fresh consent for nested skills. Dynamic skills are explicitly unverifiable and cannot receive content-bound persistent approval. SDK reads are lazy; capability negotiation never authorizes a tool or filesystem action.
The extension works in both supported protocol eras. In 2026-07-28, Skills
requests carry stateless per-request metadata and complete list results carry
resultType plus applicable cache fields. The 2025-11-25 adapter preserves
the extension methods and negotiated settings but projects away only
revision-inapplicable result fields. Skills evidence is maintained separately
from official core conformance.
{:ok, connection} =
MCP.Server.Connection.start_link(
transport: {MCP.Transport.Stdio, []},
handler: {MyApp.MCPHandler, store: MyApp.Store},
server_info: %{name: "my_server", version: "1.0.0"}
)plug =
MCP.Transport.StreamableHTTP.Plug.new(
server_mod: MyApp.MCPHandler,
handler_opts: [store: MyApp.Store],
server_opts: [
server_info: %{name: "my_server", version: "1.0.0"},
extensions: %{"com.example/audit" => %{"version" => 1}}
],
enable_json_response: false,
max_response_length: 8_000_000,
allowed_hosts: ["mcp.example.com"],
allowed_origins: ["https://app.example.com"],
legacy_endpoint_owner: MyAppWeb.Endpoint,
tool_schemas: %{
"add" => %{"type" => "object", "properties" => %{}}
}
)
{:ok, _bandit} = Bandit.start_link(plug: plug, port: 4000)Place authentication Plugs before the MCP Plug. A dynamic handler_opts
factory may read authenticated conn.assigns and return an :identity; never
derive identity from raw headers or tool arguments.
Set allowed_hosts: to the canonical Phoenix endpoint host and
allowed_origins: to trusted browser origins. Origins include their effective
port, so a port-specific entry authorizes only that port. Loopback development
defaults accept arbitrary loopback listener ports. A legacy session fingerprints
its initialization principal and re-resolves authentication on every POST, GET, and DELETE;
presenting the session ID under another principal returns 403.
The same endpoint accepts both wire eras. Stateless 2026 POSTs have no session.
Legacy 2025 initialize responses mint Mcp-Session-Id; subsequent POST/GET
requests require it, server-to-client requests flow over GET SSE, and DELETE
terminates the session. A connection selects one era and cannot mix them.
Legacy session processes are owned by the SDK application supervisor, bounded
globally and per principal, and reclaimed by idle and absolute expiry.
Initialization runs under a manager-owned task supervisor after an atomic
capacity reservation. Indexed endpoint, process, owner, and expiry state keeps
slow initialization and full-table teardown scans out of the shared manager
mailbox.
Legacy client session DELETE cleanup runs under the transport's task supervisor;
failure to start the cleanup task or failure of the DELETE is logged rather
than silently discarded.
HTTP client requests are bounded by security_policy: [max_request_bytes: 1_000_000]; response and SSE limits are separate fields. The server Plug
accepts at most max_body_length bytes inbound and emits at most
max_response_length encoded JSON or SSE bytes (both default to 8,000,000).
An oversized server result fails closed with HTTP 500 and JSON-RPC -32603,
with data.reason set to "response_too_large" and data.limit reporting the
configured limit. Stdio independently bounds inbound frames with
max_frame_bytes and outbound frames with max_outbound_frame_bytes (both
default to 1,000,000); an oversized outbound request returns
{:error, {:message_too_large, limit}} before writing.
HTTP server subscriptions additionally require a duplicate Registry and a
DynamicSupervisor. Pass both as subscription_registry: and
subscription_supervisor: and publish filtered events with
MCP.Server.SubscriptionPublisher.publish/4.
Bootstrap a fresh checkout using the checked-in Erlang and Elixir versions:
bin/setup
This requires mise, git, and jq. The setup is
idempotent: it validates the external gate dependencies and installs the pinned
toolchain, Hex, Rebar, and project dependencies. Activate mise in your shell to
run the commands below without a mise exec -- prefix.
mix precommit
# Individual gates
mix format --check-formatted
mix test
mix credo --strict
mix dialyzer
mix docs --warnings-as-errors
elixir scripts/package_smoke.exs
The pinned official harness adapters and scenario ledger live in
conformance/README.md. See
docs/dev-tooling.md
for the complete local and CI workflow.
MIT — see LICENSE.