This guide builds a small MCP server, calls it in process, and serves it over stdio and HTTP.
Install
Add snodo to your dependencies:
def deps do
[
{:snodo, "~> 0.4.3"}
]
endFor a Plug or Bandit application, also add snodo_plug:
{:snodo_plug, "~> 0.4.3"}Elixir 1.18 or later is required. The core has no runtime dependencies.
Define a server
A server module declares its components. Small ones can be written inline:
defmodule Greeter do
use Snodo.Server, name: "greeter", version: "0.1.0"
tool "greet", description: "Create a greeting" do
argument "name", :string, required: true
@impl true
def call(%{"name" => name}, _context), do: {:ok, "Hello, #{name}!"}
end
resource "profile", uri_template: "people://{name}/profile", mime_type: "application/json" do
@impl true
def read(%{"name" => name}, _context), do: {:ok, %{"name" => name}}
end
prompt "introduce", description: "Introduce someone" do
argument "name", required: true
@impl true
def render(%{"name" => name}, _context), do: {:ok, "Introduce #{name} in one sentence."}
end
endEach block becomes a module (Greeter.Tools.Greet, Greeter.Resources.Profile,
Greeter.Prompts.Introduce). Larger components are ordinary modules registered
with tool MyApp.Search. See Tools, resources, and prompts.
Arguments arrive as the protocol sends them, with string keys. A tool that
returns {:ok, binary} produces text content; any other value becomes
structured content.
Call it in process
Snodo.Client.direct/2 dispatches to the server in the calling process, which
is the quickest way to try a server and to test one:
{:ok, client} = Snodo.Client.direct(Greeter.runtime())
{:ok, tools} = Snodo.Client.list_tools(client)
{:ok, result} = Snodo.Client.call_tool(client, "greet", %{"name" => "Ada"})
result["content"]
#=> [%{"type" => "text", "text" => "Hello, Ada!"}]
{:ok, %{"contents" => [content]}} = Snodo.Client.read_resource(client, "people://ada/profile")
{:ok, %{"messages" => [message]}} = Snodo.Client.get_prompt(client, "introduce", %{"name" => "Ada"})See The client for errors, multi round-trip requests, and paging.
Test it
Snodo.Test.Assertions wraps the direct client in ExUnit assertions. Each
assertion returns the value it matched, and a failure shows the protocol
error or the tool's isError content:
defmodule GreeterTest do
use ExUnit.Case, async: true
import Snodo.Test.Assertions
alias Snodo.Client
test "greets by name" do
client = client!(Greeter.runtime())
assert_listed(Client.list_tools(client), "greet")
result = assert_tool_ok(Client.call_tool(client, "greet", %{"name" => "Ada"}))
assert [%{"text" => "Hello, Ada!"}] = result["content"]
assert_tool_error(Client.call_tool(client, "greet", %{}), "Missing required arguments")
assert_refused(Client.call_tool(client, "wave"), -32_602)
end
endassert_tool_error/2 matches a result with "isError" => true, optionally
by its text, and assert_refused/2 matches a JSON-RPC error, optionally by
its code. Had greet been called without a name under assert_tool_ok/1,
the failure would read:
Expected a successful tool result, got isError: true
content:
Missing required arguments: nameThe other helpers:
assert_input_required/2matches aninput_requiredresult, optionally of one kind (:form,:url,:sampling, or:roots).answer_input/2turns answers keyed by kind or request ID into theinput_responses:andrequest_state:options of the retry.client!/2withanswers:answers every input request inside the call instead.client_as/3builds a client whose requests carry a principal ascontext.auth, for testing an authorization policy, withassert_refused/2andrefute_listed/2.
The assertions also accept what Snodo.Test.dispatch/2 returns.
Serve it
Over stdio
An MCP host such as Claude Code, Claude Desktop, or Codex launches a stdio
server as a subprocess and talks to it on stdin and stdout. The smallest
runnable server is a script that installs snodo itself:
# greeter.exs
Mix.install([{:snodo, "~> 0.4.3"}])
defmodule Greeter do
use Snodo.Server,
name: "greeter",
version: "0.4.3",
protocols: [
Snodo.Protocol.V2026_07_28,
Snodo.Protocol.V2025_11_25,
Snodo.Protocol.V2025_06_18
]
tool "greet", description: "Create a greeting" do
argument "name", :string, required: true
@impl true
def call(%{"name" => name}, _context), do: {:ok, "Hello, #{name}!"}
end
end
:ok = Snodo.Transport.Stdio.serve(Greeter.runtime())Run it with -noinput, which lets the transport bound each input line (see
Transports):
elixir --erl -noinput greeter.exs
The first run downloads and compiles snodo; later runs reuse the
Mix.install cache.
protocols: enables the initialize-era versions next to 2026-07-28, because
hosts differ: some probe with 2026-07-28, others still send initialize with
2025-11-25 or 2025-06-18. See
Initialize-era clients.
In a Mix project, compile first so compiler output never reaches the host's stdout, then serve the module from the project directory:
mix compile
elixir --erl -noinput -S mix run --no-compile -e "Snodo.Transport.Stdio.serve(MyApp.MCPServer.runtime())"
A release can do the same from a command that starts the application and then
calls Snodo.Transport.Stdio.serve/2; put -noinput in vm.args.
Registering with a host
Hosts start the command themselves, from their own working directory, so use absolute paths. With Claude Code:
claude mcp add greeter -- elixir --erl -noinput /path/to/greeter.exs
Hosts that read a JSON configuration, such as Claude Desktop's
claude_desktop_config.json, take the same command and arguments:
{
"mcpServers": {
"greeter": {
"command": "elixir",
"args": ["--erl", "-noinput", "/path/to/greeter.exs"]
}
}
}Over HTTP
In an application, supervise the server. use Snodo.Server defines a
child_spec/1 that starts the server's transports under one supervisor and
gives each the server's runtime:
children = [
{Greeter, transports: [{Snodo.Transport.StreamableHTTP.Server, port: 4000}]}
]The endpoint is http://127.0.0.1:4000/mcp. Other options to the child are
runtime overrides, as for Greeter.runtime/1, and :supervisor_name names
the supervisor.
Which HTTP transport to use:
- The native listener above has no dependencies. Use it for a standalone server or a local tool. It serves plaintext HTTP/1.1 and binds to loopback by default; put it behind a TLS reverse proxy if other hosts reach it.
- Inside an existing Plug or Phoenix application, or when you want Bandit's
TLS and connection controls, use
snodo_plug. See Transports, and for Phoenix the router and Bandit setup.
The same client connects to either:
{:ok, client} = Snodo.Client.connect({:stdio, "elixir", ["--erl", "-noinput", "greeter.exs"]})
{:ok, client} = Snodo.Client.connect({:http, "http://127.0.0.1:4000/mcp"})Next
- Tools, resources, and prompts
- The client
- Transports
- The examples, each a runnable script