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
endSending 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/4reports progress for the request, but only when the client supplied a progress token.send_log/4emits a log record, filtered against the client's minimum log level.send_notification/2sends an arbitrary notification struct, stamping the subscription id when the notification's method requires it.send_result/2andsend_error/2deliver 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())}
endclose/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
@type log_level() ::
:debug | :info | :notice | :warning | :error | :critical | :alert | :emergency
@type meta() :: %{ client_info: GenMCP.MCP.V2607.Implementation.t() | nil, client_capabilities: GenMCP.MCP.V2607.ClientCapabilities.t() | nil, protocol_version: binary() | nil }
@type status() :: :open | :closed
Functions
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.
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
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.
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_metakey (ignored unless it names a valid level), - the client info, capabilities, and protocol version from the matching
io.modelcontextprotocol/*_metakeys, - 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.
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
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.
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
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
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
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.
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.
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))}
endThe 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.