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

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:

defp deps do
  [
    {:gen_mcp, "~> 2.0"},
  ]
end

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:

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

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:

forward "/", GenMCP.Transport.StreamableHTTP.V2511,
  server_name: "My App",
  server_version: "1.0.0",
  tools: [MyApp.Tools.Addition],
  allowed_origins: ["https://app.example.com"]

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

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 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:

# v1
alias GenMCP.MCP
alias GenMCP.MCP.TextResourceContents

# v2
alias GenMCP.MCP.V2607, as: MCP
alias GenMCP.MCP.V2607.TextResourceContents

Helper calls compile with only a 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:

# v1
{:result, MCP.call_tool_result(text: answer), channel}

# v2
{:result, MCP.call_tool_result(text: answer)}

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 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 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:

# 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

This is the one shape that changes the response on the wire: a {:result, _} answered straight from 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

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:

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

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 GenMCP.Suite.Tool.call/3 and returns {:stream, state}. Every process message the worker then receives goes to 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, _}:

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

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 GenMCP.Suite.Tool.handle_close/3 runs so the tool can cancel the job it started. A tool blocking inline is inside 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 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:

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

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:

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 GenMCP.SessionController.create/3, GenMCP.SessionController.fetch/3 and an optional 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.