Limited time 50% off everything

From the blog

How to Build an MCP Server in Elixir (Model Context Protocol)

By Liam Killingback ·

How to Build an MCP Server in Elixir (Model Context Protocol)

If you have ever wanted Claude, Cursor, or any other AI assistant to read your app’s data or call your functions directly, the Model Context Protocol is how you do it. And if you searched for how to build an MCP server in Elixir, you already noticed the problem: almost every tutorial is written for Python or TypeScript. The SERPs are a wall of fastmcp and the official TypeScript SDK, with barely a mention of the BEAM.

That is a shame, because Elixir is a fantastic fit for this. An MCP server is a long-lived process that handles concurrent tool calls, holds connections open, and needs to stay up when one request misbehaves. That is a description of what OTP was built for.

This guide builds a working MCP server in Elixir from scratch, with no framework hiding the protocol. By the end you will have a server that Claude Desktop can connect to, list your tools, and call them. We keep it honest too: there is a mature community library, and we cover when you should reach for it instead of hand-rolling everything.

This article is part of our cluster on building AI apps with Elixir and Phoenix. If you have not seen it yet, the sibling post on streaming OpenAI responses in Phoenix LiveView pairs well with this one.

What is the Model Context Protocol?

MCP is an open standard from Anthropic that lets AI applications (the “hosts”, like Claude Desktop, Cursor, or Zed) talk to external systems through a consistent interface. Instead of every tool inventing its own plugin format, a host speaks one protocol to many servers.

An MCP server exposes three kinds of capability:

  • Tools: functions the model can call, like “search orders” or “create an invoice”. This is what most people build first.
  • Resources: read-only data the host can pull in as context, addressed by URI.
  • Prompts: reusable prompt templates the user can invoke.

Under the hood the protocol is plain JSON-RPC 2.0. Every message is a JSON object with a method, some params, and usually an id. That is genuinely all it is, which is why we can implement the core in a couple of small modules.

Messages travel over a transport. There are two in common use:

  • stdio: the host launches your server as a subprocess and exchanges newline-delimited JSON over standard input and output. This is what local tools like Claude Desktop use, and it is the simplest to build.
  • Streamable HTTP: the server runs as an HTTP endpoint, which suits remote or multi-tenant deployments.

We will build the stdio transport, because it is the fastest path to something you can actually connect to Claude Desktop today.

Why Elixir for an MCP server?

A quick honest case before we write code. You do not need Elixir to build an MCP server. But if your data already lives in a Phoenix app, running the server in the same runtime means your tools are ordinary function calls against your existing contexts, with no network hop and no serialization tax.

You also get the things the BEAM is known for. Each tool call can run in its own supervised process, so a slow or crashing tool does not take the server down. Concurrency is free, which matters once a host fires several tools/call requests at once. And you can hot-path the whole thing with the same observability you already use.

Setting up the project

Start with a plain Mix project. We only need one dependency, a JSON library.

# mix.exs
defmodule McpDemo.MixProject do
  use Mix.Project

  def project do
    [
      app: :mcp_demo,
      version: "0.1.0",
      elixir: "~> 1.17",
      escript: [main_module: McpDemo.CLI],
      deps: deps()
    ]
  end

  def application do
    [extra_applications: [:logger]]
  end

  defp deps do
    [{:jason, "~> 1.4"}]
  end
end

The escript config lets us compile the server into a single executable that Claude Desktop can launch. Run mix deps.get and we are ready.

The JSON-RPC core

Every incoming message is one JSON object. Our job is to decode it, route it by method, and encode a response. Let us build the router first, because it is the heart of the server.

defmodule McpDemo.Server do
  @moduledoc "Routes JSON-RPC 2.0 messages for our MCP server."

  @protocol_version "2024-11-05"

  # Requests carry an "id" and expect a response.
  def handle(%{"method" => "initialize", "id" => id}) do
    result = %{
      protocolVersion: @protocol_version,
      capabilities: %{tools: %{}},
      serverInfo: %{name: "mcp-demo", version: "0.1.0"}
    }

    reply(id, result)
  end

  def handle(%{"method" => "tools/list", "id" => id}) do
    reply(id, %{tools: McpDemo.Tools.list()})
  end

  def handle(%{"method" => "tools/call", "id" => id, "params" => params}) do
    %{"name" => name, "arguments" => args} = params

    case McpDemo.Tools.call(name, args) do
      {:ok, text} ->
        reply(id, %{content: [%{type: "text", text: text}], isError: false})

      {:error, message} ->
        reply(id, %{content: [%{type: "text", text: message}], isError: true})
    end
  end

  # Notifications have no "id" and get no response. The client sends this
  # once after a successful initialize handshake.
  def handle(%{"method" => "notifications/initialized"}), do: :noreply

  # Anything we do not recognise gets a proper JSON-RPC error.
  def handle(%{"method" => method, "id" => id}) do
    error(id, -32601, "Method not found: #{method}")
  end

  def handle(_other), do: :noreply

  defp reply(id, result) do
    {:reply, %{jsonrpc: "2.0", id: id, result: result}}
  end

  defp error(id, code, message) do
    {:reply, %{jsonrpc: "2.0", id: id, error: %{code: code, message: message}}}
  end
end

Notice the shape of the responses. A JSON-RPC reply always carries jsonrpc: "2.0" and the same id the request used, plus either a result or an error. Notifications (messages with no id) never get a reply, which is why notifications/initialized returns :noreply.

Defining your tools

Tools are where your app becomes useful to the model. Each tool needs a name, a human description the model reads to decide when to call it, and an inputSchema written as JSON Schema so the host knows what arguments to send.

Here is a small tools module with two examples. One is pure computation, the other stands in for a real database query.

defmodule McpDemo.Tools do
  @moduledoc "The tools this MCP server exposes to the host."

  def list do
    [
      %{
        name: "get_weather",
        description: "Get the current weather for a city.",
        inputSchema: %{
          type: "object",
          properties: %{
            city: %{type: "string", description: "City name, e.g. Lisbon"}
          },
          required: ["city"]
        }
      },
      %{
        name: "count_orders",
        description: "Count orders for a customer by their email address.",
        inputSchema: %{
          type: "object",
          properties: %{
            email: %{type: "string", description: "Customer email"}
          },
          required: ["email"]
        }
      }
    ]
  end

  def call("get_weather", %{"city" => city}) do
    # In a real server this would hit a weather API with Req.
    {:ok, "It is 21 degrees and sunny in #{city}."}
  end

  def call("count_orders", %{"email" => email}) do
    # In a Phoenix app this would call your context, e.g. Orders.count_for(email).
    case fake_lookup(email) do
      nil -> {:error, "No customer found for #{email}"}
      count -> {:ok, "#{email} has placed #{count} orders."}
    end
  end

  def call(name, _args), do: {:error, "Unknown tool: #{name}"}

  defp fake_lookup("vip@example.com"), do: 42
  defp fake_lookup(_), do: nil
end

In a real Phoenix project the body of count_orders is a single call into your context module, for example MyApp.Orders.count_for(email). That is the whole point of running the server inside your app: the tool is your existing code, not a reimplementation of it.

A note on describing tools well

The description field is not documentation for humans, it is instructions for the model. Vague descriptions lead to the model calling the wrong tool or skipping it. “Count orders for a customer by their email address” tells the model exactly when this tool applies. Spend real effort here; it does more for accuracy than any amount of clever code.

Wiring up the stdio transport

Now we connect the router to the outside world. Over stdio, the host writes one JSON message per line to our standard input and reads our replies from standard output. So we read a line, decode it, hand it to Server.handle/1, and print any reply.

defmodule McpDemo.CLI do
  @moduledoc "stdio transport: read JSON-RPC lines, dispatch, write replies."

  def main(_argv) do
    # Logs must never go to stdout; that channel is reserved for protocol
    # messages. Send diagnostics to stderr instead.
    loop()
  end

  defp loop do
    case IO.read(:stdio, :line) do
      :eof ->
        :ok

      {:error, reason} ->
        IO.puts(:stderr, "read error: #{inspect(reason)}")

      line ->
        line
        |> String.trim()
        |> dispatch()

        loop()
    end
  end

  defp dispatch(""), do: :ok

  defp dispatch(line) do
    case Jason.decode(line) do
      {:ok, message} ->
        case McpDemo.Server.handle(message) do
          {:reply, response} -> IO.puts(Jason.encode!(response))
          :noreply -> :ok
        end

      {:error, _} ->
        IO.puts(:stderr, "could not parse: #{line}")
    end
  end
end

The single most common mistake with stdio servers is writing something other than protocol messages to standard output. A stray IO.puts or a library that logs to stdout will corrupt the stream and the host will drop the connection. Keep stdout clean and route every log line to stderr, as above.

Compile the escript:

mix escript.build

You now have an executable called mcp_demo in your project root.

Connecting it to Claude Desktop

Claude Desktop reads a config file that lists the MCP servers it should launch. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json. Add your server:

{
  "mcpServers": {
    "mcp-demo": {
      "command": "/absolute/path/to/mcp_demo"
    }
  }
}

Restart Claude Desktop. It launches your escript, sends an initialize request, and after the handshake calls tools/list. Your two tools now appear in the client, and you can ask the model something like “how many orders does vip@example.com have?” and watch it call count_orders.

Testing without a client

You do not want to restart a desktop app every time you change a tool. Because the whole server is just pure functions over maps, it is trivial to test with ExUnit.

defmodule McpDemo.ServerTest do
  use ExUnit.Case, async: true

  test "initialize returns our capabilities" do
    request = %{"jsonrpc" => "2.0", "id" => 1, "method" => "initialize"}
    assert {:reply, response} = McpDemo.Server.handle(request)
    assert response.result.serverInfo.name == "mcp-demo"
    assert response.id == 1
  end

  test "tools/call runs the tool and wraps the result" do
    request = %{
      "jsonrpc" => "2.0",
      "id" => 2,
      "method" => "tools/call",
      "params" => %{"name" => "count_orders", "arguments" => %{"email" => "vip@example.com"}}
    }

    assert {:reply, response} = McpDemo.Server.handle(request)
    assert [%{type: "text", text: text}] = response.result.content
    assert text =~ "42 orders"
    refute response.result.isError
  end

  test "unknown methods return a JSON-RPC error" do
    request = %{"jsonrpc" => "2.0", "id" => 3, "method" => "does/not/exist"}
    assert {:reply, response} = McpDemo.Server.handle(request)
    assert response.error.code == -32601
  end
end

This is the quiet advantage of keeping the transport separate from the router. Your protocol logic is ordinary Elixir data, so it is fast and pleasant to test.

Going to production: the honest version

What we built is real and it works, but it is deliberately minimal. Before you ship an MCP server for anything beyond a local tool, weigh a few things.

Use the community library for anything serious. The Hermes MCP project is a well-maintained Elixir SDK that handles the full protocol, both stdio and Streamable HTTP transports, resources, prompts, progress notifications, and the lifecycle edge cases we glossed over. Hand-rolling the core, as we did, is the best way to understand the protocol. For production you will usually be better served letting a library carry the spec compliance while you focus on your tools. That is a genuine strength of the ecosystem, small though it is.

Think about the transport. stdio is perfect for a local, single-user tool. If you want to expose your server to a remote host or many users, you need the Streamable HTTP transport, which you can mount as a Phoenix route. That also brings the questions stdio lets you dodge: authentication, rate limiting, and per-tenant scoping of what each tool can see.

Guard your tools. A tool is an API you are handing to a model. Validate arguments, scope database queries to the current user, and default to read-only where you can. The MCP Chat Buddy template takes exactly this posture, restricting the model to read-only SQL. You can read how that works in our MCP Chat Buddy write-up.

Supervise it. Wrap tool execution in a Task under a supervisor so one bad call cannot take the server down. This is the part where Elixir quietly pulls ahead of the single-threaded runtimes most MCP servers are written in.

Where this fits in a Phoenix app

An MCP server is most valuable when it sits on top of data you already have. If you are building AI features into a Phoenix product, the same contexts that power your LiveViews can back your MCP tools with no extra plumbing.

That is the idea behind phx_ai, our AI-native Phoenix starter. It ships the LiveView chat UI, the OpenAI integration, and the patterns you would otherwise spend a week wiring up, so you can point them at your own data and go. If you would rather see MCP working end to end against a real database, the MCP Chat Buddy template is a complete, runnable example.

Building several of these? Our Builder Pass gives you every PhxTemplates starter for a single lifetime price, which works out well once you are shipping more than one AI project. PhxTemplates is the AI-native, use-case-specific Phoenix starter, at a lifetime price.

Summary

You now have a working MCP server in Elixir, built without hiding the protocol:

  • MCP is JSON-RPC 2.0 over a transport, with tools, resources, and prompts.
  • The lifecycle is initialize, then notifications/initialized, then tools/list and tools/call.
  • stdio is the fastest way to connect to Claude Desktop; keep stdout clean and log to stderr.
  • Tools are just your existing Elixir functions, which is why running the server inside your Phoenix app is such a good fit.
  • Keeping the router pure makes the whole thing easy to test, and it sets you up to swap in the Hermes library or an HTTP transport when you outgrow the hand-rolled version.

The Elixir MCP corner of the ecosystem is still quiet, which is exactly why it is worth being early here. Your Phoenix app already holds the data these assistants want. Now you know how to hand it to them safely.