NexusMCP
MCP (Model Context Protocol) server library for Elixir.
Implements the 2025-11-25 spec over the Streamable HTTP transport, with a GenServer-per-session architecture and concurrent tool execution via Task.Supervisor.
Supports the three MCP server primitives:
- Tools — model-controlled functions (
deftool) - Prompts — user-controlled message templates (
defprompt) - Resources — application-controlled context (
defresource,defresource_template)
Installation
def deps do
[
{:nexus_mcp, "~> 0.7.0"}
]
end
Quick start
defmodule MyApp.MCP do
use NexusMCP.Server,
name: "my-app",
version: "1.0.0"
deftool "hello", "Say hello",
params: [name: {:string!, "Person's name"}] do
{:ok, "Hello, #{params["name"]}!"}
end
end
Add the supervisor to your application:
children = [
{NexusMCP.Supervisor, []},
# ...
]
Route requests to the transport:
forward "/mcp", NexusMCP.Transport, server: MyApp.MCP
Tools
Tools are exposed to MCP clients via tools/list and tools/call. Inside the do block, params and session are bound.
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, :body])}
end
Tool calls execute concurrently in supervised Task processes — slow tools don't block other RPCs on the same session.
Param types
:string, :integer, :number, :boolean, :object, plus {:array, type}. Append ! to mark required (:string!, :integer!, …). Pair with a description: {:string!, "Page ID"}.
Annotations
Add MCP tool annotations to hint behavior:
deftool "delete_item", "Delete an item",
params: [id: {:string!, "Item ID"}],
annotations: %{readOnlyHint: false, destructiveHint: true, idempotentHint: true} do
Items.delete!(params["id"])
{:ok, %{deleted: true}}
end
Supported keys: readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title.
Output schemas
Add an output schema to describe the shape of a tool's result, so clients and models can rely on its structure instead of inferring it:
deftool "get_weather", "Get current weather",
params: [city: {:string!, "City name"}],
output_schema: %{
type: "object",
properties: %{
temperature: %{type: "number", description: "Temperature in celsius"},
conditions: %{type: "string", description: "Weather conditions"}
},
required: ["temperature", "conditions"]
} do
{:ok, %{temperature: 22.5, conditions: "Partly cloudy"}}
end
The schema is advertised as outputSchema in tools/list. Tools that declare one also return their result in the structuredContent field of tools/call, alongside the serialized JSON in a text content block for backwards compatibility:
{
"content": [{ "type": "text", "text": "{\"temperature\":22.5,\"conditions\":\"Partly cloudy\"}" }],
"structuredContent": { "temperature": 22.5, "conditions": "Partly cloudy" }
}
Your handler is unchanged — the same {:ok, result} populates both fields. Errors never carry structured content.
Per the MCP specification, servers MUST provide structured results conforming to the declared schema. Two rules follow from that:
- The root type must be
"object". MCP 2025-11-25 restricts output schemas to objects, becausestructuredContentis itself typed as a JSON object. Declaring an array or scalar schema raises when the tool is defined. To return a list, wrap it:%{type: "object", properties: %{entries: %{type: "array", ...}}}. - A tool declaring a schema must return a map. Anything else — a list, a scalar, or pre-formatted content blocks — cannot conform, so it produces a tool execution error (
isError: true) instead of a successful response silently missing thestructuredContentit advertised. Unstructured content may accompany a structured result, but cannot replace it.
Tools that need to return content blocks directly simply omit output_schema.
nexus_mcp does not validate result contents against the schema — matching properties and types is a contract you are responsible for keeping.
Descriptions
Tool and param descriptions can be any expression that evaluates to a string when the module compiles: module attributes, function calls, heredocs, or a file read with @external_resource.
@account_desc "Account ID (use list_accounts to find)"
@external_resource "priv/mcp/page_guide.md"
@page_guide File.read!("priv/mcp/page_guide.md")
deftool "update_page", @page_guide,
params: [account_id: {:string!, @account_desc}, id: {:string!, "Page ID"}] do
...
end
A description that is not a string raises a compile error pointing at the deftool. Param types must still be literals.
Tool modules
A large server can be split into modules that only declare tools. Each uses NexusMCP.Tools and takes the same deftool options:
defmodule MyApp.MCP.Tools.Pages do
use NexusMCP.Tools,
params: [account_id: {:string!, "Account ID (use list_accounts to find)"}]
deftool "get_page", "Get a page", params: [id: {:string!, "Page ID"}] do
{:ok, CMS.get_page!(params["account_id"], params["id"])}
end
deftool "list_accounts", "List accounts you can access",
params: [],
skip_default_params: [:account_id] do
{:ok, Accounts.list(session.assigns.user)}
end
end
defmodule MyApp.MCP.Admin.Templates do
use NexusMCP.Tools,
meta: %{admin: true},
annotations: %{readOnlyHint: false}
deftool "delete_template", "Delete a template",
params: [id: {:string!, "Template ID"}],
annotations: %{destructiveHint: true, title: "Delete template"} do
{:ok, Templates.delete!(params["id"])}
end
end
defmodule MyApp.MCP.Server do
use NexusMCP.Server,
name: "myapp",
version: "1.0.0",
tools: [MyApp.MCP.Tools.Pages, MyApp.MCP.Admin.Templates]
@impl true
def tool_visible?(%{meta: %{admin: true}}, session), do: session.assigns[:admin?] == true
def tool_visible?(_tool, _session), do: true
end
tools/listreturns the server's owndeftools first, then each module's tools in list order.- Calls are dispatched to the module that declared the tool.
wrap_tool_call/2,tool_visible?/2, structured output, and a customhandle_tool_call/3that calls__nexus_handle_tool_call__/3all work as for the server's own tools. - Declaring the same tool name twice (in the server or any module), or listing a module that doesn't
use NexusMCP.Tools, is a compile error. - The server reads its tools modules when it compiles, so
tools/0is a fixed list and dispatch is one function clause per tool name. Editing a tools module recompiles the server module too.
Module defaults
use NexusMCP.Tools (and use NexusMCP.Server, for its own tools) accepts defaults applied to every tool in the module:
| Option | Effect |
|---|---|
:params |
Added before each tool's params. A tool replaces one by declaring the same key, or drops it with skip_default_params:. |
:meta |
Merged recursively with the tool's meta; the tool's values win. |
:annotations |
Merged with the tool's annotations; the tool's values win. |
Prompts
Prompts are user-invoked templates (e.g. slash commands) surfaced via prompts/list and prompts/get. The handler returns a list of MCP messages.
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
Required arguments are validated before the handler runs — missing required args produce a -32602 JSON-RPC error.
Resources
Resources are application-controlled context surfaced via resources/list, resources/templates/list, and resources/read.
Static resources
defresource "config://app",
name: "app_config",
description: "Application configuration",
mime_type: "application/json" do
{:ok, Jason.encode!(MyApp.config())}
end
The handler can return:
{:ok, binary}— wrapped astextifmime_typeis textual (text/*orapplication/json), otherwise base64-encoded asblob{:ok, %{text: string}}or{:ok, %{blob: base64}}— passed through{:error, :not_found}— surfaces as JSON-RPC-32002
Templated resources
Use RFC 6570 URI templates with {var} (single segment) or {+var} (multi-segment, reserved expansion):
defresource_template "file:///{path}",
name: "project_files",
description: "Files in the project directory",
mime_type: "text/plain" do
{:ok, File.read!(params["path"])}
end
defresource_template "tree:///{+path}",
name: "tree_node",
mime_type: "application/json" do
{:ok, Jason.encode!(Tree.fetch(params["path"]))}
end
URI captures land in params keyed by the template variable name.
Subscriptions (not yet supported)
Per-resource subscriptions (resources/subscribe, notifications/resources/updated) are not implemented in this release. Resources are advertised with "subscribe": false at initialization.
Per-session setup
Override init/1 to validate or enrich the session at connection time, and wrap_tool_call/2 to install process-local context (tenant ID, request span, etc.) before every tool runs:
defmodule MyApp.MCP do
use NexusMCP.Server, name: "my-app", version: "1.0.0"
@impl true
def init(session) do
case authenticate(session.assigns[:api_key]) do
{:ok, user} -> {:ok, put_in(session.assigns[:user], user)}
:error -> {:error, "unauthorized"}
end
end
@impl true
def wrap_tool_call(session, fun) do
MyApp.Context.put_user_id(session.assigns[:user].id)
fun.()
rescue
Ecto.NoResultsError -> {:error, "Not found"}
end
deftool "me", "Return the current user", params: [] do
{:ok, %{id: session.assigns[:user].id}}
end
end
Per-session tool visibility
Override tool_visible?/2 to show different tools to different sessions. It gets each tool definition and the session map, and is checked on every tools/list and tools/call. Use meta: on deftool to tag tools without hard-coding names; meta is never sent to clients.
deftool "delete_template", "Delete a template",
params: [id: {:string!, "Template ID"}],
meta: %{admin: true} do
if admin?(session), do: Templates.delete(params["id"]), else: {:error, "forbidden"}
end
@impl true
def tool_visible?(%{meta: %{admin: true}}, session), do: admin?(session)
def tool_visible?(_tool, _session), do: true
defp admin?(session), do: session.assigns[:user].role == :admin
Hidden tools are left out of tools/list. Calling one returns the same response as an unknown tool name, and neither wrap_tool_call/2 nor the handler runs. Hiding a tool is not access control: keep the permission check in the handler as well.
Transport options
forward "/mcp", NexusMCP.Transport,
server: MyApp.MCP,
allowed_origins: ["https://myapp.com", "https://studio.myapp.com"]
When allowed_origins is set, requests with an Origin header not in the list are rejected with 403. Requests without an Origin header are allowed (e.g. server-to-server).
Session lifetime and memory
defmodule MyApp.MCP do
use NexusMCP.Server,
name: "my-app",
version: "1.0.0",
hibernate_after: 60_000
end
idle_timeout (default 7_200_000, 2 hours) is how long a session may sit
idle before it is terminated.
hibernate_after (default 60_000) is how long a session must be quiet before
it hibernates. Hibernating collapses the heap the session grew while handling
requests, which the BEAM does not otherwise give back — with many concurrent
sessions holding large tool results, that heap dominates memory use.
It is debounced rather than applied per call: every request re-arms the timer,
so a session under steady traffic never hibernates and pays nothing, while one
that goes quiet releases its heap and re-grows on the next message. Hibernating
does not extend the inactivity deadline. Set hibernate_after: :infinity to
disable it.
Distributed deployments
Session registry is swappable. Provide your own implementation of NexusMCP.SessionRegistry (e.g. backed by :global, :pg, or Horde) and configure it:
config :nexus_mcp, registry: MyApp.DistributedRegistry
Spec coverage
This release implements the MCP 2025-11-25 server spec for:
initialize+notifications/initializedpingtools/list,tools/call(with annotations, output schemas, and structured content)prompts/list,prompts/getresources/list,resources/templates/list,resources/read
Out of scope for this release (tracked separately):
resources/subscribe,resources/unsubscribe,notifications/resources/updatednotifications/{prompts,resources}/list_changedcompletion/complete- Pagination cursors on
*/listmethods (whole list returned in one page) - Full RFC 6570 URI template grammar (currently
{var}and{+var})
License
MIT