Packages

A generic MCP server behaviour for the latest protocol version with a suite of components to build tools, resources and prompts.

Current section

Files

Jump to
gen_mcp lib gen_mcp.ex
Raw

lib/gen_mcp.ex

defmodule GenMCP do
@moduledoc ~S"""
Behaviour for an MCP server that handles JSON-RPC requests over the stateless
`2026-07-28` transport.
A module implementing `GenMCP` answers Model Context Protocol requests: listing
and calling tools, listing and reading resources, listing and getting prompts,
and the `server/discover` capability snapshot. The library runs the
implementation **per request**. For every incoming message the transport starts
a fresh worker process that calls `c:init/1` to build the state, then the
matching handler. The per-request client context (client info, capabilities,
negotiated protocol version, authorization assigns) is read from the
`t:GenMCP.Mux.Channel.t/0` passed to every callback, not from `c:init/1`.
## Choosing between `GenMCP.Suite` and a custom implementation
For most servers, reach for `GenMCP.Suite` instead of implementing this
behaviour yourself. `GenMCP.Suite` is a ready-made `GenMCP` implementation, and
the default server, that serves tools, resources, and prompts from a composable
set of providers: you describe *what* to expose and it handles the protocol
wiring. It fits the general, high-level case, and it is where to start.
Implement `GenMCP` directly when you need tight control over request handling,
for a specific need the provider model does not cover: routing raw requests
yourself, driving the SSE stream by hand, or shaping responses that the
providers do not express. The rest of this document is for that case.
## One process per request
Each request gets its **own dedicated process**, and every callback for that
request runs **in that same process**, one after another: `c:init/1`, then the
handler, then any `c:handle_message/3` calls, then `c:handle_close/2`. So a
handler can keep transient data in `state`, read its process mailbox, and block
safely without affecting anyone else. Separate requests run in **separate
processes** and share none of that state.
## Minimal implementation
A server that exposes a single `add` tool. It advertises the tool on
`tools/list` and runs it on `tools/call`, delegating the real work to a plain
`Calculator` module so the server stays a thin protocol adapter:
defmodule MyServer do
@behaviour GenMCP
alias GenMCP.MCP.V2607, as: MCP
@impl true
def init(_arg) do
{:ok, %{}}
end
@impl true
def handle_request(%MCP.ListToolsRequest{}, _channel, _state) do
{:result, MCP.list_tools_result([Calculator.tool()])}
end
def handle_request(%MCP.CallToolRequest{params: %{name: "add"}} = request, _channel, _state) do
%{"a" => a, "b" => b} = request.params.arguments
{:result, MCP.call_tool_result(text: "#{Calculator.add(a, b)}")}
end
def handle_request(_request, _channel, _state) do
{:error, :method_not_found}
end
@impl true
def handle_notification(_notification, _channel, _state) do
:ok
end
@impl true
def handle_message(_message, _channel, _state) do
{:stop, :normal}
end
end
The `Calculator` module owns the tool's schema and its logic, with no MCP
concern of its own:
defmodule Calculator do
alias GenMCP.MCP.V2607, as: MCP
def tool do
%MCP.Tool{
name: "add",
description: "Adds two numbers and returns the sum.",
inputSchema: %{
"type" => "object",
"properties" => %{
"a" => %{"type" => "number"},
"b" => %{"type" => "number"}
},
"required" => ["a", "b"]
}
}
end
def add(a, b) do
a + b
end
end
`c:handle_close/2` is optional, so `MyServer` does not define it.
## Wiring a server into the transport
An implementation is handed to `GenMCP.Transport.StreamableHTTP` (the HTTP plug)
through its `:server` option, usually from a router. The default `:server` is
`GenMCP.Suite`, so you set the option only for a custom implementation:
forward "/mcp", GenMCP.Transport.StreamableHTTP, server: MyServer
When `:server` is a bare module, `c:init/1` receives the leftover transport
options as a keyword list. Pass `{MyServer, arg}` to hand `c:init/1` an explicit
`arg` instead:
forward "/mcp", GenMCP.Transport.StreamableHTTP, server: {MyServer, mode: :read_only}
## Terminate or keep streaming
Every request follows one of two paths. A handler either **terminates** the
request with a single response, or **keeps it streaming**:
- `c:handle_request/3` returns `{:result, result}` or `{:error, reason}` to
answer immediately, or `{:stream, state}` to hold the response open as a
Server-Sent Events stream.
- While streaming, the worker forwards every Erlang message it receives to
`c:handle_message/3` with the carried `state`. The stream stays open as long
as `c:handle_message/3` returns `{:stream, state}`, and ends when it returns
`{:result, result}`, `{:error, reason}`, or `{:stop, reason}`.
State is carried **only** by the `{:stream, state}` return, because that is the
only return with a successor callback. Terminal returns end the worker, so they
carry no state.
Because the worker is the request's own process, a handler that just needs to
compute or wait does **not** need to stream: it may block in `c:handle_request/3`
(including awaiting a `Task`) and return `{:result, result}` when done. Streaming
earns its place when the result is produced **elsewhere** and arrives as a
message. The server below hands the work to a job queue, keeps the stream open,
and finishes the request when the queue messages the worker back:
def handle_request(%MCP.CallToolRequest{} = request, _channel, _state) do
{:ok, job_id} = MyApp.JobQueue.enqueue(self(), request.params.arguments)
{:stream, %{job_id: job_id}}
end
def handle_message({:job_finished, job_id, output}, _channel, %{job_id: job_id}) do
{:result, MCP.call_tool_result(text: output)}
end
A streaming handler can also report progress and logs to the client through the
channel, with `GenMCP.Mux.Channel.send_progress/4` and
`GenMCP.Mux.Channel.send_log/4`.
"""
alias GenMCP.MCP.V2607, as: MCP
alias GenMCP.MCP.V2607.ModMap
alias GenMCP.Mux.Channel
require ModMap
ModMap.require_all()
@type state :: term
# TODO(005) exhaustive list of requests/results/notifications types
@type request ::
MCP.ListToolsRequest.t()
| MCP.CallToolRequest.t()
| MCP.ListResourcesRequest.t()
| MCP.ReadResourceRequest.t()
| MCP.ListResourceTemplatesRequest.t()
| MCP.ListPromptsRequest.t()
| MCP.GetPromptRequest.t()
@type result ::
MCP.ListToolsResult.t()
| MCP.CallToolResult.t()
| MCP.ListResourcesResult.t()
| MCP.ReadResourceResult.t()
| MCP.ListResourceTemplatesResult.t()
| MCP.ListPromptsResult.t()
| MCP.GetPromptResult.t()
# The multi round-trip ask (MRTR, spec 007). Just another result: a
# handler that needs more input from the client returns it via the
# normal `{:result, result}` path. `GenMCP.Suite` builds it from a
# tool's `{:input_required, …}` return (encrypting the continuation
# into `requestState`); a custom server may build it directly.
| MCP.InputRequiredResult.t()
# Graceful teardown of a `subscriptions/listen` stream: `GenMCP.Suite`
# returns this when the subscription handler stops an established
# stream, carried back to the client over the normal `{:result, _}`
# path (`resultType: "complete"`). See
# `c:GenMCP.Suite.SubscriptionHandler.handle_message/4`.
| MCP.SubscriptionsListenResult.t()
@type notification ::
MCP.CancelledNotification.t()
| MCP.ProgressNotification.t()
@doc """
Builds the per-request state before any handler runs.
`init/1` is called once for every incoming request or notification, on a fresh
worker, before the matching handler. Keep it cheap: the stateless core runs it
on the hot path of each message, not once per session.
The argument is the server configuration. When the server is wired as a bare
module (`server: MyServer`), `init/1` receives the leftover transport options as
a keyword list. When wired as `{MyServer, arg}`, it receives `arg` unchanged.
Return `{:ok, state}` to proceed, where `state` is threaded into the handler, or
`{:stop, reason}` to abort the request before it is handled.
"""
@callback init(init_arg :: term) :: {:ok, state} | {:stop, term}
@doc """
Handles one MCP request and either answers it or upgrades it to a stream.
This is the primary callback. It receives the decoded request struct, the
request's `t:GenMCP.Mux.Channel.t/0`, and the `state` from `c:init/1`. Match on
the request struct to route the call. The request types are listed in
`t:request/0`: `tools/list`, `tools/call`, the `resources/*` and `prompts/*`
requests, and the `server/discover` capability snapshot.
Return one of:
- `{:result, result}` answers the request and ends it. Build `result` with the
helpers in `GenMCP.MCP.V2607`, for example `GenMCP.MCP.V2607.list_tools_result/2`
or `GenMCP.MCP.V2607.call_tool_result/1`.
- `{:result, result, stop_reason}` answers the request with `result`, then stops
the worker process with `stop_reason` instead of the default `{:shutdown,
:reply}`. The client sees the same response; `stop_reason` only sets the
worker's exit reason, observable to whatever monitors or supervises it (and in
telemetry). Use `:normal`, `:shutdown`, or `{:shutdown, term}` for a clean exit.
- `{:error, reason}` ends the request with a JSON-RPC error.
- `{:stream, state}` holds the response open as an SSE stream and routes every
later message to `c:handle_message/3` with the returned `state`.
A handler that answers with `{:result, result}` after a long stretch of work
can open the stream while it works by calling
`GenMCP.Mux.Channel.start_stream/1` on its `channel`, which gets the response
its periodic keepalives.
### Examples
Answer `tools/call` for one known tool and reject the rest:
@impl true
def handle_request(%MCP.CallToolRequest{params: %{name: "ping"}}, _channel, _state) do
{:result, MCP.call_tool_result(text: "pong")}
end
def handle_request(%MCP.CallToolRequest{}, _channel, _state) do
{:error, :method_not_found}
end
"""
@callback handle_request(request, Channel.t(), state) ::
{:result, result}
| {:result, result, stop_reason :: term}
| {:error, reason :: term}
| {:stream, state}
@doc """
Observes a client notification. Returns `:ok`.
Each client notification arrives as its own HTTP POST that is answered with
`202 Accepted` and never streams, so there is nothing to return beyond `:ok`,
and no state is carried forward. The notification struct, its own
`t:GenMCP.Mux.Channel.t/0`, and the `state` from `c:init/1` are passed in. The
notification types are listed in `t:notification/0`.
The channel is the notification's **own** per-request context, a sibling of any
in-flight request rather than a handle to it. Read `channel.meta` (client info,
capabilities, authorization assigns) to decide whether to trust the sender. A
notification cannot reach or cancel another request: on this transport,
cancellation is signalled by the client closing the connection, which the
framework already turns into `c:handle_close/2`.
The default behaviour, and a fine implementation when there is nothing to
observe, is to accept and ignore:
@impl true
def handle_notification(_notification, _channel, _state) do
:ok
end
"""
@callback handle_notification(notification, Channel.t(), state) :: :ok
@doc """
Handles a process message while a request is streaming.
This callback runs only after `c:handle_request/3` returned `{:stream, state}`.
Once a request is streaming, the worker forwards **every** Erlang message it
receives to this callback, so a handler that spawns tasks, subscribes to a
`Phoenix.PubSub` topic, or monitors another process receives those messages
here. It is passed the raw message, the request's `t:GenMCP.Mux.Channel.t/0`,
and the current `state`.
Return one of:
- `{:stream, state}` keeps the stream open and waits for the next message,
carrying the updated `state`.
- `{:result, result}` ends the stream with the request's final result.
- `{:result, result, stop_reason}` ends the stream with `result`, then stops the
worker process with `stop_reason` instead of the default `{:shutdown, :reply}`.
The client sees the same final result; `stop_reason` only sets the worker's
exit reason. Use `:normal`, `:shutdown`, or `{:shutdown, term}` for a clean exit.
- `{:error, reason}` ends the stream with a JSON-RPC error.
- `{:stop, reason}` ends the stream with no further result, for example when a
process the handler was listening to exits normally. Use `:normal`,
`:shutdown`, or `{:shutdown, term}` for a clean exit.
Send intermediate progress and log notifications through the channel with
`GenMCP.Mux.Channel.send_progress/4` and `GenMCP.Mux.Channel.send_log/4` before
returning.
### Examples
A handler waiting on a background job. The job sends its own status messages to
the worker process; an update keeps the stream open and reports progress to the
client, and the completion message ends the request:
@impl true
def handle_message({:job_update, job_id, done, total}, channel, %{job_id: job_id} = state) do
GenMCP.Mux.Channel.send_progress(channel, done, total)
{:stream, state}
end
def handle_message({:job_finished, job_id, output}, _channel, %{job_id: job_id}) do
{:result, MCP.call_tool_result(text: output)}
end
"""
@callback handle_message(message :: term, Channel.t(), state) ::
{:stream, state}
| {:result, result}
| {:result, result, stop_reason :: term}
| {:stop, reason :: term}
| {:error, reason :: term}
@doc """
Cleans up when a streaming request is closed from the connection side. Optional.
This callback fires only when a streaming request is torn down by something
other than the handler's own return value — practically, when the client
disconnects or the network fails (the transport-level cancellation signal on
this binding). It is **not** called when the handler ends the request itself by
returning `{:result, …}`, `{:stop, …}`, or `{:error, …}` from
`c:handle_request/3` or `c:handle_message/3`; those finish the request directly,
with no cleanup hook. (A handler that closes its own stream with
`GenMCP.Mux.Channel.close/1` is the one server-side exception that still runs
this callback.)
It receives the request's `t:GenMCP.Mux.Channel.t/0`, now marked closed, and the
last `state`. It is the place to release resources the handler acquired while
streaming, such as unsubscribing from a `Phoenix.PubSub` topic. The return value
is ignored.
The callback is optional. Define it only when a streaming handler holds
resources that must be released on disconnect:
@impl true
def handle_close(_channel, state) do
Phoenix.PubSub.unsubscribe(MyApp.PubSub, state.topic)
end
"""
@callback handle_close(Channel.t(), state) :: term
@optional_callbacks handle_close: 2
@doc """
Returns the list of MCP protocol versions this library supports (currently
`["2026-07-28"]`).
This is the allowlist the transport checks an incoming `MCP-Protocol-Version`
against. A request carrying a version not in this list is rejected with an
`UnsupportedProtocolVersionError` (`-32004`) whose `data.supported` is this
list.
"""
def supported_protocol_versions do
["2026-07-28"]
end
@doc """
Returns the MCP protocol version this library targets (currently `"2026-07-28"`).
"""
def protocol_version do
"2026-07-28"
end
@doc """
Attaches the default `:telemetry` logger for `:gen_mcp` events.
Convenience wrapper that delegates to `GenMCP.TelemetryLogger.attach/1`. Call
it once at startup to get `Logger` output for the library's lifecycle and
transport events. See `GenMCP.TelemetryLogger` for the events and their log
levels, and `GenMCP.TelemetryLogger.attach/1` for the available `filters`.
:ok = GenMCP.attach_default_logger()
"""
def attach_default_logger(filters \\ []) do
GenMCP.TelemetryLogger.attach(filters)
end
@doc """
Returns the historical default channel log level (`:notice`).
> #### Deprecated {: .warning}
>
> The stateless core no longer applies a library-wide default log level. A
> channel's level is read per request from the `io.modelcontextprotocol/logLevel`
> `_meta` field, and its absence means logging is **disabled**, not `:notice`.
> See `GenMCP.Mux.Channel.send_log/4`.
"""
@default_channel_log_level :notice
def default_channel_log_level do
@default_channel_log_level
end
end