# Upgrading to v2

Version 2 moves `gen_mcp` to the `2026-07-28` protocol:
`GenMCP.Transport.StreamableHTTP` now speaks that version and its stateless
transport. The `2025-06-18` and `2025-11-25` protocols your deployed clients
still speak are served by a compatibility transport,
`GenMCP.Transport.StreamableHTTP.V2511`.

On the wire, nothing changes for your clients: swap the plug mounted on your
existing route for the compatibility one and they keep calling tools as
before. In the code, the upgrade is a small port: the entity namespace, the
tool return tuples and the async API changed, and most of those changes
compile without errors, failing only on the first tool call. Grep your project
for `GenMCP.MCP`, result tuples ending in `channel`, `:async`, `continue(`,
`GenMCP.Mux`, `send_message`, `[:gen_mcp, :session` and `config :gen_mcp`;
every hit is covered by a section below.

> #### The compatibility transport serves tool calls {: .warning}
>
> The 2025 transport is a migration shim: it serves the surface a client needs
> to keep calling tools. That surface is `initialize`,
> `notifications/initialized`, `ping`, `logging/setLevel`, `tools/list` and
> `tools/call`, with progress and log notifications delivered on the request's
> own SSE response, plus the `GET` notification stream described below. Any
> other method is answered with a JSON-RPC `-32601` (method not found) error.

## Update the dependency

Bump `gen_mcp` in your dependencies:

<!-- rdmx :app_dep vsn:"2.0.0" -->
```elixir
defp deps do
  [
    {:gen_mcp, "~> 2.0"},
  ]
end
```
<!-- rdmx /:app_dep -->

If your v1 config sets `config :gen_mcp, node_id: ...`, delete the line. v2
reads no application environment, and the token sessions described below carry
everything they need, with no node identity to configure.

## Swap the transport on the existing route

Replace `GenMCP.Transport.StreamableHTTP` with
`GenMCP.Transport.StreamableHTTP.V2511` in the existing `forward` — the server
options are the same. Mount the 2026 transport on a path of its own; both
mounts share the same tools:

<!-- rdmx :section name:v2_router format:true -->
```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  scope "/mcp" do
    # New version
    forward "/stateless", GenMCP.Transport.StreamableHTTP,
      server_name: "My App",
      server_version: "1.0.0",
      tools: [MyApp.Tools.Addition]

    # Old version on the same path
    forward "/", GenMCP.Transport.StreamableHTTP.V2511,
      server_name: "My App",
      server_version: "1.0.0",
      tools: [MyApp.Tools.Addition]
  end
end
```
<!-- rdmx /:section -->

The compatibility transport accepts a few options of its own, such as the
session controller described below, and forwards everything else to the server
implementation, exactly as `GenMCP.Transport.StreamableHTTP` does. The full
option list is in `GenMCP.Transport.StreamableHTTP.V2511`. If your router
raises when one module is forwarded on several routes (older Phoenix releases
do), define named plug copies with
`GenMCP.Transport.StreamableHTTP.V2511.defplug/1`.

### Allow the origins your browser clients send

Both transports carry DNS-rebinding protection, which is new in v2: a request
whose `Origin` header is not in the mount's `:allowed_origins` is answered with
`403 Forbidden`. The option defaults to `[]`, so list the origins your
browser-based clients call from as part of the upgrade:

<!-- rdmx :section name:origins format:true -->
```elixir
forward "/", GenMCP.Transport.StreamableHTTP.V2511,
  server_name: "My App",
  server_version: "1.0.0",
  tools: [MyApp.Tools.Addition],
  allowed_origins: ["https://app.example.com"]
```
<!-- rdmx /:section -->

Behind a gateway that already validates the origin, set `allowed_origins: :any`
to leave the check to the gateway.

> #### Your test suite passes either way {: .warning}
>
> A request that carries no `Origin` header at all is always accepted, and most
> non-browser clients (`Req`, the CLI clients, your own test client) send none.
> So a mount left on the default allowlist looks healthy locally and in CI, and
> the `403` appears only once a browser-based client calls it. Confirm the
> allowlist against where your clients actually run rather than against a green
> suite.

## Tool API changes

The protocol contract is version-independent: a 2025 `tools/call` is
translated at the transport boundary into a `GenMCP.MCP.V2607.CallToolRequest`,
so `c:GenMCP.Suite.Tool.call/3` receives the same request struct from both
transports, and the arguments are validated against the tool's input schema
either way. The client name and version and the negotiated protocol version
from the `initialize` handshake reach the tool through the `GenMCP.Mux.Channel`
metadata, populated as they would be from a 2026 request.

The Elixir API your tool modules are written against did change. Compile with
`mix compile --warnings-as-errors` during the upgrade: two of the three changes
below surface as warnings only, and the third gives no signal at all.

### Entities and helpers moved to `GenMCP.MCP.V2607`

The entity namespace now carries the protocol version. Update the aliases; the
helper signatures are unchanged, so call sites behind an alias stay as they
are:

<!-- rdmx :section name:mcp_alias format:true -->
```elixir
# v1
alias GenMCP.MCP
alias GenMCP.MCP.TextResourceContents

# v2
alias GenMCP.MCP.V2607, as: MCP
alias GenMCP.MCP.V2607.TextResourceContents
```
<!-- rdmx /:section -->

> #### Helper calls compile with only a warning {: .warning}
>
> A struct reference like `%GenMCP.MCP.ReadResourceResult{}` fails compilation
> outright, but a helper call like `MCP.call_tool_result(...)` under the old
> alias only emits a `module GenMCP.MCP is not available` warning and then
> raises `UndefinedFunctionError` on the first tool call. A project whose
> tools only call helpers compiles successfully and ships broken, so treat
> those warnings as errors.

### `call/3` return tuples carry no channel

The result tuples lost their trailing channel element:

<!-- rdmx :section name:call_return format:true -->
```elixir
# v1
{:result, MCP.call_tool_result(text: answer), channel}

# v2
{:result, MCP.call_tool_result(text: answer)}
```
<!-- rdmx /:section -->

A stale three-element tuple is still a valid tuple, so nothing warns at
compile time; the worker rejects it when the tool is called. Once ported,
`channel` becomes unused in many `c:GenMCP.Suite.Tool.call/3` heads, so expect
a wave of unused variable warnings and rename those to `_channel`.

### Porting async tools

`{:async, {tag, task}, channel}` and the `continue/3` callback are gone. The v1
API existed because one process served all of a session's requests: a tool
blocking in `call/3` blocked the whole session, so slow work had to move to a
task. v2 spawns a dedicated worker process per request, so
`c:GenMCP.Suite.Tool.call/3` can block for as long as the work takes, and each
former async tool ports to one of the three shapes below.

#### Work that finishes quickly

Run it inline and answer:

<!-- rdmx :section name:sync_port format:true -->
```elixir
# v1
def call(request, channel, _arg) do
  {:async, {:search, Task.async(fn -> MyApp.Search.run(request) end)}, channel}
end

def continue({:search, {:ok, response}}, channel, _arg) do
  {:result, MCP.call_tool_result(text: response), channel}
end

# v2
def call(request, _channel, _arg) do
  {:result, MCP.call_tool_result(text: MyApp.Search.run(request))}
end
```
<!-- rdmx /:section -->

This is the one shape that changes the response on the wire: a `{:result, _}`
answered straight from `c:GenMCP.Suite.Tool.call/3` is sent as a direct
`application/json` response, where the v1 async tool answered over SSE. Clients
accept both, but tests asserting the SSE shape of a formerly async tool need
updating to read a JSON body.

> #### A synchronous port writes nothing until it finishes {: .warning}
>
> A v1 `{:async, ...}` tool answered over SSE, so the transport chunked a
> `:keepalive` every 25s and the connection stayed alive for as long as the work
> took. A plain `{:result, _}` port compiles, passes its tests, and returns the
> same payload, but writes zero bytes until the work is done. Any proxy, load
> balancer or client idle timeout in the path then kills the request. Open the
> stream as below for work that runs longer than the smallest idle timeout in
> your path.

#### Work that takes a while

Open the stream before starting the work, then run it inline and answer as
above. `GenMCP.Mux.Channel.start_stream/1` commits the response to SSE without
sending anything on it, which is what gets the keepalives flowing:

<!-- rdmx :section name:slow_port format:true -->
```elixir
def call(request, channel, _arg) do
  :ok = Channel.start_stream(channel)
  {:result, MCP.call_tool_result(text: MyApp.Search.run(request))}
end
```
<!-- rdmx /:section -->

#### Work driven by messages from elsewhere

A tool that waits on another process, a `Phoenix.PubSub` topic or a job queue
reporting progress, subscribes in `c:GenMCP.Suite.Tool.call/3` and returns
`{:stream, state}`. Every process message the worker then receives goes to
`c:GenMCP.Suite.Tool.handle_message/4` as it arrives, raw, with the state
carried from the previous return. Translate the ones you care about, and finish
with `{:result, _}`:

<!-- rdmx :section name:stream_port format:true -->
```elixir
def call(request, _channel, _arg) do
  %{"query" => query} = request.params.arguments
  {:ok, job_id} = MyApp.Search.start(query)
  :ok = Phoenix.PubSub.subscribe(MyApp.PubSub, "search:#{job_id}")
  {:stream, job_id}
end

def handle_message({:search_progress, job_id, done, total}, channel, job_id, _arg) do
  Channel.send_progress(channel, done, total)
  {:stream, job_id}
end

def handle_message({:search_finished, job_id, output}, _channel, job_id, _arg) do
  {:result, MCP.call_tool_result(text: output)}
end
```
<!-- rdmx /:section -->

This shape also decides what happens when the client hangs up mid-call, which is
the real difference between it and the inline one above. Here the worker sits
idle between messages, so it sees the disconnect immediately and stops, and the
optional `c:GenMCP.Suite.Tool.handle_close/3` runs so the tool can cancel the job
it started. A tool blocking inline is inside `c:GenMCP.Suite.Tool.call/3` for the
whole call and learns of the disconnect when the work returns. Reach for this
shape when the work is expensive enough to be worth abandoning, such as a paid
API call, and for the inline one when the connection just needs to stay open.

A leftover `continue/3` definition only draws a `got "@impl true" for function
continue/3 but no behaviour specifies such callback` warning and lingers as
dead code, so delete it as part of the port. `GenMCP.Suite.Tool` documents the
full streaming contract, including progress and log notifications sent from
`c:GenMCP.Suite.Tool.handle_message/4`.

### Channel functions

`GenMCP.Mux.Channel.send_progress/4` and `GenMCP.Mux.Channel.send_log/4` are
unchanged. The raw `send_message/2` is replaced by
`GenMCP.Mux.Channel.send_notification/2`, which takes a notification struct or
map rather than an encoded binary. Channel assigns are set from the transport
mount options, `:assigns` and `:copy_assigns`, instead of `assign/3` calls.

### Unchanged surfaces

These carry over without edits, so there is no need to audit them:

* `use GenMCP.Suite.Tool` options (`:name`, `:description`, `:input_schema`,
  `:output_schema`) and the `c:GenMCP.Suite.Tool.info/2`,
  `c:GenMCP.Suite.Tool.input_schema/1`, `c:GenMCP.Suite.Tool.output_schema/1`
  and `c:GenMCP.Suite.Tool.validate_request/2` callbacks.
* `GenMCP.Suite.ResourceRepo`: `c:GenMCP.Suite.ResourceRepo.prefix/1`,
  `c:GenMCP.Suite.ResourceRepo.list/3` and
  `c:GenMCP.Suite.ResourceRepo.read/3`, with the same return shapes.
* `GenMCP.Suite.Extension`: `c:GenMCP.Suite.Extension.tools/2`,
  `c:GenMCP.Suite.Extension.resources/2` and
  `c:GenMCP.Suite.Extension.prompts/2`.
* The `GenMCP.MCP.V2607.call_tool_result/1` and
  `GenMCP.MCP.V2607.read_resource_result/1` option shapes.
* `GenMCP.TelemetryLogger.attach/1`.

## Sessions

The 2025 protocol is stateful: `initialize` answers with an `Mcp-Session-Id`
header, and the client sends that id back on every later request. The default
session controller, `GenMCP.SessionController.Token`, keeps the server side of
the upgrade stateless by sealing the session data into the id itself: creating
a session is an encrypt, reading it back is a decrypt, and a session minted on
one node is readable on any node sharing the configuration. A minted id is an
opaque encrypted string of roughly 200 characters, sized to travel as an HTTP
header.

The sealing uses your endpoint's `secret_key_base`, so mount the compatibility
transport inside a Phoenix endpoint.

> #### gen_mcp currently requires Phoenix {: .warning}
>
> The key is read from the `Phoenix.Endpoint` on the conn, so both transports
> have to be mounted inside a Phoenix endpoint. `:phoenix` is a regular
> dependency of `gen_mcp`, not an optional one. Mounted in a plain
> `Plug.Router` the channel carries no endpoint, and minting a token raises
> `ArgumentError`:
>
> ```text
> the channel carries no endpoint: the transport found no Phoenix endpoint in
> the conn, so there is no key to encrypt or decrypt tokens with
> ```
>
> This covers more than 2025 session ids. The same key encrypts pagination
> cursors and the `requestState` of an `input-required` result on the 2026
> transport. The router starts fine and the mount looks correct either way —
> the failure happens on the first request that mints a token.

Sessions last one day. To pick another lifetime, pass the controller as a
`{module, arg}` tuple in the forward options, with a `:max_age` in seconds,
for example
`session_controller: {GenMCP.SessionController.Token, max_age: 3600}`. A
client showing up with an expired or unknown session id is answered `404`, the
2025 signal to run `initialize` again.

To keep the full client info server-side, or to revoke sessions, implement the
`GenMCP.SessionController` behaviour and pass your module as the
`:session_controller` option. If you implemented the v1
`GenMCP.Suite.SessionController` behaviour, port it to this one: it is much
smaller, with `c:GenMCP.SessionController.create/3`,
`c:GenMCP.SessionController.fetch/3` and an optional
`c:GenMCP.SessionController.delete/3`, and the session it stores holds only
the negotiated protocol version and the client name and version.

## Server-to-client notifications

A 2025 client opens a long-lived `GET` request to receive server-initiated
notifications. The compatibility transport serves that stream through the
Suite's `:subscription_handler` option, the same
`GenMCP.Suite.SubscriptionHandler` that serves `subscriptions/listen` on the
2026 transport, and translates the 2026 subscription vocabulary away on the
wire. Add a handler to the forward options to enable the stream, for example
`subscription_handler: MyApp.ToolChanges`.

With no handler configured, `GET` answers `405 Method Not Allowed`, which the
2025 spec permits.

Once every client speaks `2026-07-28`, delete the compatibility forward.

## Telemetry

The event names follow the per-request architecture. The v1
`[:gen_mcp, :session, ...]` and `[:gen_mcp, :cluster, ...]` events are
replaced by `[:gen_mcp, :server, :init]` and
`[:gen_mcp, :server, :start_error]`, emitted per worker, and by the transport
events `[:gen_mcp, :transport, :request_rejected]`,
`[:gen_mcp, :transport, :server_crashed]` and
`[:gen_mcp, :transport, :version_rejected]`. Handlers you attach yourself need
the new names; `GenMCP.TelemetryLogger.attach/1` needs no change.

## Testing

Every request is served by a fresh worker process, spawned when the request
arrives and gone once it is answered. In tests that call the server over HTTP,
run process-ownership stubbing tools in their shared or global mode, for
example `Req.Test.set_req_test_to_shared/1` or `Mox.set_mox_global/1`, both of
which require `async: false`. This replaces the v1 pattern of looking up the
session process with `GenMCP.Mux.whereis/1` to grant it an allowance: the
worker pid now exists only while its request is in flight.
