Model Context Protocol for Elixir โ€” server and client โ€” targeting spec revision 2025-11-25 (negotiates down to 2025-06-18).

  • ๐Ÿงฉ Declarative components โ€” tools (compile-time schema DSL โ†’ JSON Schema, validated atom-keyed args via JSV, 2020-12 dialect), resources + RFC 6570 templates + subscriptions, prompts, completion
  • ๐Ÿงฐ Toolkits โ€” many small tools in one module via @mcp function annotations, with schemas as plain data or raw JSON text
  • ๐Ÿ—‚๏ธ Hidden items & discovery โ€” hidden: true keeps any tool, prompt, or resource callable but unlisted; a built-in catalog tool plus category metadata (_meta.category) give agents a discovery surface
  • โš™๏ธ Behaviour-driven core โ€” every macro is sugar over plain callbacks you can implement by hand
  • ๐Ÿ”Œ Transports: stdio and Streamable HTTP (Plug โ€” mount in Phoenix or run standalone on Bandit) on both the server and the client side
  • โ†”๏ธ Full bidirectionality: server handlers can sample, elicit, and list_roots against the connected client mid-call
  • ๐Ÿ” OAuth 2.1: resource-server enforcement (TokenVerifier, WWW-Authenticate, RFC 9728 metadata) and a full client flow (discovery, PKCE S256, refresh, resource indicators, scope step-up)
  • ๐Ÿงช First-class testing with Noizu.MCP.Test over an in-memory transport (async: true safe), plus conformance checks against the official spec schema
  • ๐Ÿ“ˆ Concurrent request handling per session โ€” slow tools never block ping, cancellation, or progress

Status: pre-release (0.1.x). All protocol features above are implemented and covered by 240+ tests including real-subprocess stdio e2e and Bandit HTTP round-trips. Pre-1.0 API may still move.

Quickstart: a stdio server

# mix.exs
{:noizu_mcp, "~> 0.1"}

Define a tool and a server:

defmodule MyApp.Tools.GetWeather do
  use Noizu.MCP.Server.Tool,
    name: "get_weather",
    description: "Get current weather for a location",
    annotations: [read_only_hint: true]

  input do
    field :location, :string, required: true, description: "City name or zip code"
    field :units, :enum, values: [:celsius, :fahrenheit], default: :celsius
  end

  output do
    field :temperature, :number, required: true
    field :conditions, :string, required: true
  end

  @impl true
  def call(%{location: location, units: _units}, ctx) do
    Noizu.MCP.Ctx.report_progress(ctx, 0.5, message: "querying provider")
    {:ok, %{temperature: 21.5, conditions: "clear over #{location}"}}
  end
end

defmodule MyApp.MCP do
  use Noizu.MCP.Server,
    name: "myapp",
    version: "1.0.0",
    instructions: "Weather tools for MyApp."

  tool MyApp.Tools.GetWeather
end

Run it over stdio from your application supervisor:

children = [
  {MyApp.MCP, transport: :stdio}
]

Register with Claude Code:

claude mcp add myapp -- mix run --no-halt

Arguments arrive validated and atom-keyed (defaults applied, enums cast to atoms). Validation failures are returned to the model as isError: true tool results it can self-correct from. Return values can be a string, a structured map (validated against output), Noizu.MCP.Types.Content blocks, or a full ToolResult; {:error, "msg"} produces an execution error, raising produces a sanitized one.

stdout is sacred. On stdio transports, anything printed to stdout corrupts the protocol stream. The transport automatically diverts the default Logger handler to stderr โ€” avoid IO.puts/1 in handler code, and prefer OTP releases over mix run in production.

Toolkits: multiple tools per module

For a bundle of small tools, skip the one-module-per-tool ceremony: use Noizu.MCP.Server.Toolkit turns @mcp-annotated functions into tools, with schemas declared as plain data (or raw JSON text):

defmodule MyApp.Toolkit do
  use Noizu.MCP.Server.Toolkit, category: "Utility"   # default category

  @mcp name: "files.read", category: "Files", description: "Read a file",
       input: [path: [type: :string, required: true]]
  def read_file(%{path: path}, _ctx) do
    case File.read(path) do
      {:ok, data} -> {:ok, data}
      {:error, reason} -> {:error, "read failed: #{reason}"}
    end
  end

  @mcp description: "Server time (name derives from the function)"
  def server_time, do: {:ok, to_string(DateTime.utc_now())}

  @mcp visible: false   # hidden from tools/list, still callable
  @mcp input: """
  {"type": "object", "properties": {"q": {"type": "string"}}}
  """
  def lookup(args, _ctx), do: {:ok, args["q"] || ""}
end

defmodule MyApp.MCP do
  use Noizu.MCP.Server, name: "myapp", version: "1.0.0"

  tool MyApp.Toolkit              # registers every annotated function
  # tool MyApp.Toolkit, category: "Admin", hidden: true  # opts apply kit-wide
end

Annotated functions take (args, ctx), (args), or no arguments. The data-form input: spec gives you the same validated, atom-keyed, default-applied, enum-cast arguments as the classic input do ... end DSL; a map or JSON-text string is treated as a raw JSON Schema instead. category: rides on the wire in _meta.category and is filterable through the catalog tool below. Full details โ€” @mcp option table, merge semantics, the three schema forms โ€” in the Toolkits, Categories & Hidden Tools guide.

Hidden tools & discovery

Mark any tool, prompt, resource, or resource template hidden: true to omit it from tools/list / prompts/list / resources/list responses while keeping it fully callable by name via tools/call, prompts/get, and resources/read โ€” useful for internal, privileged, or agent-only surface area you don't want crowding the default listing.

defmodule MyApp.Tools.Internal do
  use Noizu.MCP.Server.Tool,
    name: "internal_tool",
    description: "Agent-only tool",
    hidden: true
  # ...
end

defmodule MyApp.MCP do
  use Noizu.MCP.Server, name: "myapp", version: "1.0.0"

  tool MyApp.Tools.Internal                       # hidden via module flag
  tool MyApp.Tools.GetWeather, hidden: true      # hidden via registration override
  tool Noizu.MCP.Server.Tools.Catalog, hidden: true
end

The registration-level hidden: option overrides the module default in either direction (visible: false is accepted as an alias for hidden: true; for toolkit registrations it applies to every tool in the kit). The built-in Noizu.MCP.Server.Tools.Catalog tool lets agents discover unpublished items: it returns full wire definitions (input schemas included) for everything registered, each tagged with a "hidden" flag, with type/query/category/include_hidden filters.

Call dispatch never consults the hidden flag, so hidden items resolve whether or not they were listed. For session-gated visibility (an "unlock" flow), override handle_list_tools/2 with include_hidden: driven by session state and push notify_changed(:tools) when it flips โ€” worked example in the Toolkits, Categories & Hidden Tools guide.

Streamable HTTP (Phoenix / Bandit)

# Phoenix router
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP

# or standalone
{Bandit, plug: {Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP}, port: 4040}

Sessions, SSE upgrades, Last-Event-ID resumability, origin validation, and DELETE teardown are handled per spec. Protect it as an OAuth 2.1 resource server with auth: [verifier: {MyVerifier, []}, resource_metadata: "..."] (see Noizu.MCP.Auth.TokenVerifier).

Consuming servers (client)

children = [
  {Noizu.MCP.Client,
   name: MyApp.FS,
   transport: {:stdio, command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]},
   # or: transport: {:streamable_http, url: "https://api.example.com/mcp",
   #                 auth: {Noizu.MCP.Auth.Static, token: token}}
   handler: MyApp.MCPHandler}   # answers sampling/elicitation; see Noizu.MCP.Client.Handler
]

{:ok, tools}  = Noizu.MCP.Client.list_tools(MyApp.FS)
{:ok, result} = Noizu.MCP.Client.call_tool(MyApp.FS, "read_file", %{"path" => "/tmp/a.txt"},
                  timeout: 60_000, progress: fn p -> IO.inspect(p) end)

Inspector

mix mcp.client launches a native HTML inspector (similar to the official MCP Inspector) for exploring and exercising MCP servers interactively โ€” tools with auto-generated forms, resources, prompts, raw JSON-RPC history, notifications, and a Pending tab for answering server-initiated sampling and elicitation requests without writing any handler code.

# launch with no target and pick/switch servers inside the app
mix mcp.client

# in-process server module
mix mcp.client MyApp.MCP

# spawn an external stdio server
mix mcp.client --stdio "npx -y @modelcontextprotocol/server-everything"

# connect to a remote Streamable HTTP server
mix mcp.client --url http://localhost:4040/mcp --bearer TOKEN

Add :bandit and :plug (dev-only) to use it; :req is also required for --url targets. See guides/inspector.md for the full option reference, tab tour, sampling/elicitation walkthrough, security notes, and programmatic embedding via Noizu.MCP.Inspector.start_link/1.

Testing your server

defmodule MyApp.MCPTest do
  use ExUnit.Case, async: true
  import Noizu.MCP.Test

  setup do: %{client: connect(MyApp.MCP)}

  test "get_weather", %{client: client} do
    assert {:ok, result} = call_tool(client, "get_weather", %{"location" => "NYC"})
    assert result.structured["temperature"]
    assert_progress(client)
  end
end

Escape hatch: no macros

Everything the DSL generates is an overridable callback:

defmodule MyApp.RawMCP do
  use Noizu.MCP.Server, name: "raw", version: "1.0.0"

  @impl true
  def handle_list_tools(_cursor, _ctx),
    do: {:ok, [%Noizu.MCP.Types.Tool{name: "echo"}], nil}

  @impl true
  def handle_call_tool("echo", args, _ctx), do: {:ok, inspect(args)}
end

Documentation

Guides on hexdocs: Getting Started ยท Tools & Schemas ยท Toolkits & Discovery ยท Resources & Prompts ยท the Handler Context ยท Client ยท Streamable HTTP ยท stdio ยท Authentication ยท Testing ยท MCP Inspector โ€” plus a cheatsheet.

Examples

  • examples/echo_stdio โ€” minimal stdio server, ready for claude mcp add
  • examples/no_dsl_server โ€” behaviour-only server (no macros), hand-written schemas and dynamic dispatch
  • examples/http_kitchen_sink โ€” Streamable HTTP server on Bandit exercising the full feature surface (progress, cancellation, sampling, subscriptions, templates, completion, a toolkit module, hidden tools + the catalog discovery tool)
  • examples/agent_client โ€” client demo: spawns echo_stdio over stdio, lists and calls tools with progress, answers elicitations

Development

mix test                 # unit + integration + spec conformance
mix test --include e2e   # also drive examples/echo_stdio as a real subprocess

License

MIT