GenMCP.Mux.Channel (gen_mcp v2.0.0)

Copy Markdown View Source

Per-request channel for pushing notifications, logs, and the final reply back to an MCP client.

A channel is the server side of one request. The transport builds it from the incoming HTTP request and hands it to your GenMCP (or GenMCP.Suite) callbacks as the channel argument. You never construct one yourself: you receive it and call functions on it to send messages over the open response stream.

Because the 2026-07-28 core is stateless, a channel lives only for the request that created it. It carries everything the server needs to talk back to the client without a session: the client process to deliver messages to, the request id, the progress token the client supplied, the negotiated minimum log level, and the client metadata (info, capabilities, protocol version) read from the request _meta.

The common case is a request handler that reports progress and logs while it works, then returns its result:

defmodule MyApp.Server do
  @behaviour GenMCP

  alias GenMCP.Mux.Channel
  alias GenMCP.MCP.V2607, as: MCP

  @impl true
  def init(arg), do: {:ok, arg}

  @impl true
  def handle_request(_request, channel, state) do
    Channel.send_log(channel, :info, "starting work")
    Channel.send_progress(channel, 50, 100)
    Channel.send_progress(channel, 100, 100)
    {:result, MCP.call_tool_result(text: "done")}
  end
end

Sending messages

Each send function delivers one message to the client and returns :ok (or {:ok, channel} for the reply functions). They all short-circuit with {:error, :closed} once the channel has been closed.

  • send_progress/4 reports progress for the request, but only when the client supplied a progress token.
  • send_log/4 emits a log record, filtered against the client's minimum log level.
  • send_notification/2 sends an arbitrary notification struct, stamping the subscription id when the notification's method requires it.
  • send_result/2 and send_error/2 deliver the final reply for the request.

Lifecycle

A channel is :open from the moment the transport builds it until something closes it, and every send works while it is open. A handler ends the response itself with close/1, which is how it stops a long-lived stream from the server side. set_closed/1 and as_closed/1 mark a channel closed without telling the client, which is what the transport does once the client has already disconnected. A send on a closed channel returns {:error, :closed}.

Streaming

The response is a single JSON reply until there is something to stream on it, and it becomes an SSE stream from the first thing sent. send_progress/4, send_log/4 and send_notification/2 each start the stream as they send, and start_stream/1 starts it on its own. Reach for start_stream/1 when a handler is about to spend a long time working before it has anything to send: the transport writes periodic keepalives on an open stream, and those keep a proxy or a client from dropping the connection as idle.

def handle_request(_request, channel, state) do
  :ok = Channel.start_stream(channel)
  {:result, MCP.call_tool_result(text: MyApp.Report.build())}
end

close/1 ends the response, whether it is an open stream or a reply still to be written.

Summary

Functions

Returns a closed copy of the channel with no client attached.

Ends the request's stream from the server side.

Stamps the channel's request id into a notification's subscription id.

Builds a channel for an incoming request, targeting the calling process.

Returns whether a notification must be stamped with a subscription id.

Sends the final error reply for the request.

Sends a log record to the client, filtered by the client's minimum level.

Sends a notification to the client.

Sends a progress notification for the request.

Sends the final successful reply for the request.

Marks the channel closed without telling the client.

Turns the response into a stream without sending anything on it.

Types

log_level()

@type log_level() ::
  :debug | :info | :notice | :warning | :error | :critical | :alert | :emergency

meta()

@type meta() :: %{
  client_info: GenMCP.MCP.V2607.Implementation.t() | nil,
  client_capabilities: GenMCP.MCP.V2607.ClientCapabilities.t() | nil,
  protocol_version: binary() | nil
}

status()

@type status() :: :open | :closed

t()

@type t() :: %GenMCP.Mux.Channel{
  client: pid() | nil,
  endpoint: nil | module(),
  log_level: log_level() | nil,
  meta: meta(),
  progress_token: nil | binary() | integer(),
  request_id: binary() | integer() | nil,
  status: status()
}

Functions

as_closed(t)

Returns a closed copy of the channel with no client attached.

The status is set to :closed and the client process is cleared, so the channel can be carried in state to satisfy a callback signature while silently dropping any send.

close(channel)

Ends the request's stream from the server side.

A handler calls this to close a long-lived stream itself, for example to stop a subscriptions/listen stream it owns. The client is told to end the response, and the returned channel is marked closed so any later send returns {:error, :closed}. This ends the response whether or not it ever became a stream.

Returns {:ok, channel} with the closed channel, or {:error, :closed} when it was already closed.

Examples

End a subscriptions/listen stream from the channel your handler was given, for example when the app signals there is nothing left to watch:

def handle_message({:source_drained, _}, channel, state) do
  {:ok, _closed} = Channel.close(channel)
  {:stop, :normal}
end

copy_subscription_id(channel, notification)

Stamps the channel's request id into a notification's subscription id.

The id is written under the io.modelcontextprotocol/subscriptionId key inside the notification's params._meta, which lets the client demultiplex the notification onto the matching subscriptions/listen stream. The key already present (string or atom form) is respected, otherwise the form is chosen from the notification's own shape. A notification that already carries a non-nil subscription id is left unchanged, as is a channel with no request id.

send_notification/2 calls this for you when the notification's method requires it, so you rarely call it directly.

from_request(conn, req, meta_assigns \\ %{})

Builds a channel for an incoming request, targeting the calling process.

The transport calls this while handling a request. The returned channel delivers its messages to self(), the process that builds it, which owns the HTTP response stream.

Values are read from the parsed req:

  • the progress token from req.params._meta.progressToken,
  • the minimum log level from the io.modelcontextprotocol/logLevel _meta key (ignored unless it names a valid level),
  • the client info, capabilities, and protocol version from the matching io.modelcontextprotocol/* _meta keys,
  • the request id from req.id.

The endpoint is taken from a Phoenix.Endpoint on the conn when present. meta_assigns is a map merged into the channel meta, used to carry application assigns (such as the authorization context copied from the connection) alongside the protocol metadata.

requires_subscription_id?(arg1)

Returns whether a notification must be stamped with a subscription id.

This is true for the notification methods that are delivered on a subscriptions/listen stream (the various list_changed, resources/updated, and subscriptions/acknowledged methods), and false for anything else. The method is read from a "method" or :method key, or from the method/0 function of a notification struct.

send_notification/2 uses this to decide whether to call copy_subscription_id/2.

Examples

iex> GenMCP.Mux.Channel.requires_subscription_id?(%{method: "notifications/resources/updated"})
true

iex> GenMCP.Mux.Channel.requires_subscription_id?(%{method: "notifications/message"})
false

send_error(channel, error)

Sends the final error reply for the request.

The error counterpart of send_result/2. Returns {:ok, channel} once the error is delivered, or {:error, :closed} when the channel is already closed.

send_log(channel, level, data, logger \\ nil)

Sends a log record to the client, filtered by the client's minimum level.

The client sets a minimum level on the request. A record is delivered only when its level is at or above that minimum, using the standard syslog severity order (:debug through :emergency). A record below the minimum is dropped silently and the call still returns :ok. When the request did not enable logging at all, every record is dropped the same way.

The arguments are:

  • channel - the request's channel.
  • level - the severity, one of :debug, :info, :notice, :warning, :error, :critical, :alert, :emergency.
  • data - the payload to log, any JSON-encodable term.
  • logger - an optional logger name string attached to the record.

Returns :ok when the record is delivered or intentionally filtered, {:error, :invalid_level} for an unknown level, and {:error, :closed} once the channel is closed.

Examples

Log from the channel your callback was handed. Records below the client's minimum level are dropped for you, so you can log freely:

def handle_request(_request, channel, state) do
  Channel.send_log(channel, :info, "starting work")
  Channel.send_log(channel, :error, "database unreachable", "MyApp.Repo")
  {:result, result}
end

send_notification(channel, notification)

Sends a notification to the client.

Pass any notification struct from the GenMCP.MCP.V2607 vocabulary (or a plain map carrying a method). When the notification's method is one delivered on a subscription stream, the channel stamps the subscription id into its _meta before sending, so the client can route it to the right subscriptions/listen stream. See requires_subscription_id?/1 and copy_subscription_id/2 for that mechanism.

Returns :ok on delivery, or {:error, :closed} once the channel is closed.

Examples

Send a notification from the channel your callback was handed. For a list-changed method, the channel stamps the subscription id for you before delivering:

def handle_message({:tools_changed, _}, channel, state) do
  Channel.send_notification(channel, %MCP.ToolListChangedNotification{})
  {:stream, state}
end

send_progress(channel, progress, total \\ nil, message \\ nil)

Sends a progress notification for the request.

Progress is reported only when the client supplied a progress token with the request. Without one there is nothing to correlate the update against, so the call returns {:error, :no_progress_token} and nothing is sent.

The arguments are:

  • channel - the request's channel.
  • progress - the amount of progress so far, as a number.
  • total - the optional total amount of work, when known.
  • message - an optional human-readable status string.

Returns :ok on delivery, {:error, :no_progress_token} when the client did not request progress, and {:error, :closed} once the channel is closed.

Examples

Report progress from the channel your callback was handed, as the work advances:

def handle_request(_request, channel, state) do
  Channel.send_progress(channel, 25, 100, "indexing")
  # ... more work ...
  Channel.send_progress(channel, 100, 100)
  {:result, result}
end

send_result(channel, payload)

Sends the final successful reply for the request.

Returns {:ok, channel} once the payload is delivered, or {:error, :closed} when the channel is already closed. See send_error/2 for the failure reply.

set_closed(channel)

Marks the channel closed without telling the client.

Returns the channel with its status set to :closed, so any later send returns {:error, :closed}. Unlike close/1, this sends nothing to the client. The transport uses it after the client has already disconnected.

start_stream(channel)

Turns the response into a stream without sending anything on it.

A handler with something to send starts the stream as it sends, through send_progress/4, send_log/4 or send_notification/2. Call start_stream/1 when the response has to stay open before there is anything to put on it, which is the case for a handler doing slow work inline. The transport writes periodic keepalives on an open stream, so the connection survives work that takes a while:

def call(request, channel, _arg) do
  :ok = Channel.start_stream(channel)
  {:result, MCP.call_tool_result(text: MyApp.Search.run(request))}
end

The response is then text/event-stream and the result is delivered as its last event. Calling this more than once, or after something has already been sent, leaves the one stream in place.

The worker stays inside the callback for the whole of the slow work, so it observes a client disconnect once that work returns. To abandon expensive work as soon as the client goes away, return {:stream, state} and await a supervised task instead, as GenMCP.Suite.Tool.handle_message/4 shows.

Returns :ok, or {:error, :closed} once the channel is closed. See close/1 to end the response.