A practical guide to building MCP clients and servers with ExMCP.

Table Of Contents

  1. Installation
  2. Server DSL
  3. Low-Level Handlers
  4. BEAM-Local MCP
  5. Clients
  6. Protocol Versions
  7. Protocol-Deprecated Features
  8. Transports
  9. Resilience And Pipelines
  10. Troubleshooting

Installation

def deps do
  [
    {:ex_mcp, "~> 1.0.0-rc.8"}
  ]
end

Server DSL

Use ExMCP.Server.Handler with ExMCP.Server.DSL for most servers:

defmodule MyServer do
  use ExMCP.Server.Handler
  use ExMCP.Server.DSL, name: "my-server", version: "1.0.0"

  tool "echo", "Echoes the input message" do
    param :message, :string, required: true

    run fn %{message: message}, state ->
      {:ok, %{content: [%{type: "text", text: message}]}, state}
    end
  end

  resource "config://app", "Application configuration" do
    mime_type "application/json"

    read fn _params, state ->
      {:ok, %{text: Jason.encode!(%{debug: false})}, state}
    end
  end

  prompt "summarize", "Summarize text" do
    arg :text, required: true

    render fn %{text: text}, state ->
      {:ok,
       %{
         messages: [
           %{role: "user", content: %{type: "text", text: "Summarize: #{text}"}}
         ]
       }, state}
    end
  end
end

Start it with the transport you need:

{:ok, server} = MyServer.start_link(transport: :beam)

Low-Level Handlers

Use handwritten callbacks when capabilities are fully dynamic or you need custom behavior. For nearly all cases, the DSL is simpler and recommended:

defmodule MyServer do
  use ExMCP.Server.Handler
  use ExMCP.Server.DSL, name: "my-server", version: "1.0.0"

  tool "ping", "Health check" do
    run fn _args, state ->
      {:ok, %{content: [%{type: "text", text: "pong"}]}, state}
    end
  end
end

{:ok, server} = MyServer.start_link(transport: :beam)

Raw Callback Example

defmodule DynamicServer do
  use ExMCP.Server.Handler

  @impl true
  def handle_initialize(_params, state) do
    {:ok,
     %{
       protocolVersion: ExMCP.protocol_version(),
       serverInfo: %{name: "dynamic", version: "1.0.0"},
       capabilities: %{tools: %{}}
     }, state}
  end

  @impl true
  def handle_list_tools(_cursor, state) do
    tools = [
      %{
        name: "ping",
        description: "Health check",
        inputSchema: %{type: "object", properties: %{}}
      }
    ]

    {:ok, tools, nil, state}
  end

  @impl true
  def handle_call_tool("ping", _args, state) do
    {:ok, %{content: [%{type: "text", text: "pong"}]}, state}
  end
end

# Start a raw handler (no DSL):
{:ok, server} =
  ExMCP.Server.HandlerServer.start_link(
    handler: DynamicServer,
    transport: :beam
  )
# Or the convenience:
# {:ok, server} = ExMCP.start_server(handler: DynamicServer, transport: :beam)

BEAM-Local MCP

Use transport: :beam when both sides are Elixir processes in the same VM. When using the DSL the server module gets a start_link/1:

{:ok, server} = MyServer.start_link(transport: :beam)

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :beam,
    server: server
  )

{:ok, tools} = ExMCP.Client.list_tools(client)
{:ok, result} = ExMCP.Client.call_tool(client, "echo", %{"message" => "hello"})

For a raw handler (no DSL) use ExMCP.Server.HandlerServer.start_link(handler: MyHandler, ...) (or ExMCP.start_server/1).

Tip: mix examples.getting_started (after mix compile) gives a fast local run of these DSL + Client patterns for quick verification.

BEAM-local MCP follows the selected protocol mode. rc.8 defaults to :prefer_modern, which uses discovery and per-request context; :legacy_only uses the legacy initialize handshake. In either era, the transport passes MCP-shaped maps/lists as Elixir terms instead of JSON strings.

Clients

Connect to stdio:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :stdio,
    command: ["node", "server.js"],
    cd: "/path/to/project",
    env: [{"NODE_ENV", "production"}]
  )

Connect to Streamable HTTP:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    protocol_mode: :prefer_modern,
    use_sse: true,
    headers: [{"Authorization", "Bearer #{token}"}]
  )

Call server features:

{:ok, tools} = ExMCP.Client.list_tools(client)
{:ok, result} = ExMCP.Client.call_tool(client, "search", %{"query" => "Elixir"})
{:ok, resources} = ExMCP.Client.list_resources(client)
{:ok, content} = ExMCP.Client.read_resource(client, "file:///docs/readme.md")
{:ok, prompts} = ExMCP.Client.list_prompts(client)

Protocol Versions

MCP 2026-07-28 is the latest stable revision. It is wire-incompatible with the legacy 2024-11-05, 2025-03-26, 2025-06-18, and 2025-11-25 revisions, so ExMCP selects an era with protocol_mode:

config :ex_mcp, protocol_mode: :prefer_modern

Use :prefer_modern for a dual-era client or server that tries 2026-07-28 first, :modern_only for a closed modern ecosystem, :prefer_legacy for an early compatibility canary, and :legacy_only to preserve the legacy protocol era. Exact rc.5 wire and session behavior still requires package rollback to 1.0.0-rc.5. rc.8 defaults to :prefer_modern; the published rc.5 package remains the legacy-only characterization baseline and does not contain these modes.

ExMCP.protocol_version/0 returns 2025-11-25 because it is a legacy initialize compatibility helper; it does not report the latest upstream revision. See the Configuration Guide for negotiation, fallback, and per-connection overrides.

Protocol-Deprecated Features

MCP 2026-07-28 deprecates Roots, Sampling, and protocol Logging, but keeps them in the specification for at least twelve months. ExMCP retains their callbacks, functions, capability declarations, legacy methods, and modern MRTR handling throughout the 1.x line. Existing integrations can continue to use them while migrating; new integrations should use these replacements:

Deprecated MCP featureRecommended replacement
Roots (roots/list, root callbacks and notifications)Pass directories or files through tool parameters, resource URIs, or server configuration
Sampling (sampling/createMessage)Call the chosen LLM provider API directly from application code
Logging (logging/setLevel, notifications/message, per-request log level)Write stdio diagnostics to stderr and export structured observability through OpenTelemetry

Roots are informational hints, not an authorization boundary. Continue to enforce filesystem and resource permissions independently during migration. Sampling compatibility handlers must retain human approval for any legacy server-initiated model request.

Transports

TransportUse When
:stdioSpawning an MCP subprocess
:httpTalking to a remote or Phoenix-hosted MCP server
:beamConnecting local Elixir client/server processes
:testUnit/integration tests

Resilience And Pipelines

Use client retries for transient connection/request failures:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    retry_policy: [max_attempts: 3, initial_delay: 100, max_delay: 2_000]
  )

Use transport reliability when a circuit breaker or health check belongs at the connection boundary:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    reliability: [
      circuit_breaker: [failure_threshold: 5, reset_timeout: 30_000],
      health_check: [check_interval: 60_000]
    ]
  )

For HTTP servers, put side-effecting concerns such as authentication, request signing, CORS, and DNS rebinding protection in the Plug/Phoenix pipeline before ExMCP.HttpPlug.

Troubleshooting

BEAM-local client cannot connect

Process.alive?(server)
ExMCP.Client.start_link(transport: :beam, server: server)

stdio server exits immediately

Make sure command includes the executable and arguments as a list, and use cd/env if the subprocess needs a specific working directory or environment.

HTTP connection refused

Verify the URL path matches the server endpoint. ExMCP.Transport.HTTP extracts the path from url unless endpoint: is provided explicitly.

Need HTTP auth or validation

Use headers, auth, auth_provider, security, or Plug composition around ExMCP.HttpPlug depending on whether the concern is client-side or server-side.