NexusMCP - MCP server library with per-session GenServer architecture

Hey everyone,

I’ve just published NexusMCP, an MCP (Model Context Protocol) server library for Elixir.

Why another MCP library?

When I needed to add MCP to a production app a few months back, I tried the existing options - primarily anubis_mcp and vancouver. I ran into bugs that were dealbreakers for production use:

  1. Session expiry didn’t work properly - timed-out sessions would linger instead of returning 404, leading to confusing client behavior
  2. Tool call responses came back as SSE streams instead of inline JSON -the Streamable HTTP spec says POST responses should return JSON directly, not force everything through SSE
  3. Zombie sessions - non-initialize requests to invalid sessions would silently create new sessions instead of returning 404

Rather than patching around these, I decided to build something from scratch that leans into OTP patterns. I’ve been running it in production for a while now and just got around to publishing it.

Since I started building, emcp was also released I believe it takes a different approach with ETS tables, but haven’t looked at it much. Would have if it was out before I started on this :grinning_face_with_smiling_eyes:.

The approach

NexusMCP uses a GenServer-per-session architecture. Each MCP client gets its own process, which gives you:

  • Parallel tool execution - tool calls run concurrently via Task.Supervisor.async_nolink, so clients can fire off multiple tool calls and they execute simultaneously without blocking each other
  • Process isolation - one session crashing doesn’t affect others, and a crashed tool call doesn’t kill the session
  • Idle timeout cleanup - sessions auto-terminate after inactivity (configurable, default 2 hours), no zombie sessions
  • Swappable session registry - defaults to Elixir’s Registry for single-node, but the behaviour is pluggable for distributed setups (Horde, :global, etc.)

DSL for defining tools

defmodule MyApp.MCP do
  use NexusMCP.Server,
    name: "my-app",
    version: "1.0.0"

  deftool "get_page", "Get a page by ID",
    params: [id: {:string!, "Page ID"}] do
    page = CMS.get_page!(params["id"])
    {:ok, Map.take(page, [:id, :title, :slug])}
  end
end

The deftool macro handles schema generation, parameter validation, and handler dispatch at compile time. There's also a wrap_tool_call/2 callback for setting up process-local context (tenant context, etc.) or rescuing common errors.

What's new in v0.2.0

Just pushed a couple of updates today:

- Tool annotations - readOnlyHint, destructiveHint, idempotentHint, etc. per the latest MCP spec
- Origin validation - optional allowed_origins on the transport for restricting which origins can connect
- Protocol version bump to 2025-06-18

deftool "delete_item", "Delete an item",
  params: [id: {:string!, "Item ID"}],
  annotations: %{destructiveHint: true, idempotentHint: true} do
  Items.delete!(params["id"])
  {:ok, %{deleted: true}}
end

Setup

# mix.exs
{:nexus_mcp, "~> 0.2.0"}

# application.ex
children = [{NexusMCP.Supervisor, []}]

# router.ex
forward "/mcp", NexusMCP.Transport,
  server: MyApp.MCP,
  allowed_origins: ["https://myapp.com"]

Minimal dependencies - just plug and jason.

Would love feedback, issues, or PRs. Cheers!

8 Likes