GenMCP behaviour (gen_mcp v2.0.0)

Copy Markdown View Source

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 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 GenMCP.Mux.Channel.t/0 passed to every callback, not from 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: init/1, then the handler, then any handle_message/3 calls, then 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

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, init/1 receives the leftover transport options as a keyword list. Pass {MyServer, arg} to hand 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:

  • 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 handle_message/3 with the carried state. The stream stays open as long as 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 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.

Summary

Callbacks

Cleans up when a streaming request is closed from the connection side. Optional.

Handles a process message while a request is streaming.

Observes a client notification. Returns :ok.

Handles one MCP request and either answers it or upgrades it to a stream.

Builds the per-request state before any handler runs.

Functions

Attaches the default :telemetry logger for :gen_mcp events.

Returns the historical default channel log level (:notice).

Returns the MCP protocol version this library targets (currently "2026-07-28").

Returns the list of MCP protocol versions this library supports (currently ["2026-07-28"]).

Types

Callbacks

handle_close(t, state)

(optional)
@callback handle_close(GenMCP.Mux.Channel.t(), state()) :: term()

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 handle_request/3 or 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 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

handle_message(message, t, state)

@callback handle_message(message :: term(), GenMCP.Mux.Channel.t(), state()) ::
  {:stream, state()}
  | {:result, result()}
  | {:result, result(), stop_reason :: term()}
  | {:stop, reason :: term()}
  | {:error, reason :: term()}

Handles a process message while a request is streaming.

This callback runs only after 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 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

handle_notification(notification, t, state)

@callback handle_notification(notification(), GenMCP.Mux.Channel.t(), state()) :: :ok

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 GenMCP.Mux.Channel.t/0, and the state from init/1 are passed in. The notification types are listed in 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 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

handle_request(request, t, state)

@callback handle_request(request(), GenMCP.Mux.Channel.t(), state()) ::
  {:result, result()}
  | {:result, result(), stop_reason :: term()}
  | {:error, reason :: term()}
  | {:stream, state()}

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 GenMCP.Mux.Channel.t/0, and the state from init/1. Match on the request struct to route the call. The request types are listed in 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 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

init(init_arg)

@callback init(init_arg :: term()) :: {:ok, state()} | {:stop, term()}

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.

Functions

attach_default_logger(filters \\ [])

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

default_channel_log_level()

Returns the historical default channel log level (:notice).

Deprecated

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.

protocol_version()

Returns the MCP protocol version this library targets (currently "2026-07-28").

supported_protocol_versions()

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.