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:

Installation

def deps do
  [
    {:nexus_mcp, "~> 0.3.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.

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:

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

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).

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:

Out of scope for this release (tracked separately):

License

MIT