# `GenMCP.Mux.Channel`
[🔗](https://github.com/lud/gen_mcp/blob/main/lib/gen_mcp/mux/channel.ex#L1)

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.

# `log_level`

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

# `meta`

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

# `status`

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

# `t`

```elixir
@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()
}
```

# `as_closed`

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`

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`

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`

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?`

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`

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`

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`

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`

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`

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`

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`

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 `c:GenMCP.Suite.Tool.handle_message/4` shows.

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
