Dmk
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:
- Session expiry didn’t work properly - timed-out sessions would linger instead of returning 404, leading to confusing client behavior
- 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
- 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
.
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
Registryfor 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!
First Post!
Dmk
A little updated to share - v0.3.0 is out, adding the other two MCP primitives so the server DSL now covers all three:
- tools
- prompts
- 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”}
Last Post!
Dmk
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
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #hex









