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.

https://github.com/dmkenney/nexus_mcp

Would love feedback, issues, or PRs. Cheers!

8 Likes

A little updated to share - v0.3.0 is out, adding the other two MCP primitives so the server DSL now covers all three:

  1. tools
  2. prompts
  3. resources

Prompts (defprompt) surface user-invoked templates via prompts/list / prompts/get:

defprompt "code_review", "Ask the model to review code",
  arguments: [code: {:string!, "The code to review"}] do
  {:ok, [%{role: "user",
           content: %{type: "text", text: "Please review:\n" <> params["code"]}}]}
end

Resources (defresource, defresource_template) handle static and URI-templated context. Return values get coerced to MCP’s text/blob shape based on mime_type:

defresource_template "file:///{path}",
  name: "project_files",
  mime_type: "text/plain" do
  {:ok, File.read!(params["path"])}
end

Targeting the 2025-11-25 spec. Still TODO: resources/subscribe, list-changed notifications, completion/complete, and pagination cursors.

{:nexus_mcp, “~> 0.3.0”}

Small one this time: v0.3.1 is out, a bugfix release.

If you’re running NexusMCP across a distributed cluster with a custom SessionRegistry (e.g. a :pg-backed one), an RPC to a session living on a node that has left the cluster, say mid-deploy during a rolling restart, would exit with {{:nodedown, node}, _}. The transport wasn’t catching that, so the request process crashed instead of failing gracefully.

It now degrades to a 404 (session not found), and any other unexpected exit from the session call is logged and handled the same way rather than taking the request down with it. Single-node setups aren’t affected; this only shows up once sessions can live on a node that can disappear out from under you.

Thanks to @ryancurtin for the report and the fix. He hit it in a production ECS rolling deploy and traced it down to the exact exit reason (PR #1).

{:nexus_mcp, “~> 0.3.1”}

Hex: nexus_mcp | Hex
HexDocs: NexusMCP v0.3.1 — Documentation
Changelog: Release v0.3.1 · dmkenney/nexus_mcp · GitHub