Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

 
 

Repository files navigation

MCP Elixir SDK

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.1 is a release candidate. Its handler API is a breaking cutover from 1.x, while its wire protocol remains compatible with 2025-11-25 peers.

What 2.0 provides

  • Dual-version selection: clients prefer 2026-07-28, then perform bounded fallback to 2025-11-25 only for lifecycle/version incompatibility signals.
  • Version-isolated lifecycles: 2026 requests use server/discover and 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 gated resources/directory/read. The implementation is pinned to SEP PR head 753b9f2be43e07fdd070e535d75f190cff14beea (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.

Installation

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"}
  ]
end

No 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.

Client

{: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

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.content

Server 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}}.

Client subscriptions

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}
end

Each 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.

Server handler

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
end

The 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 extension security boundary

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.

Stdio or in-process connection

{: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"}
  )

Streamable HTTP

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.

Development and verification

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.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages