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"},
]
endIf 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
endThe 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.TextResourceContentsHelper 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))}
endThis 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))}
endWork 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)}
endThis 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:
use GenMCP.Suite.Tooloptions (:name,:description,:input_schema,:output_schema) and theGenMCP.Suite.Tool.info/2,GenMCP.Suite.Tool.input_schema/1,GenMCP.Suite.Tool.output_schema/1andGenMCP.Suite.Tool.validate_request/2callbacks.GenMCP.Suite.ResourceRepo:GenMCP.Suite.ResourceRepo.prefix/1,GenMCP.Suite.ResourceRepo.list/3andGenMCP.Suite.ResourceRepo.read/3, with the same return shapes.GenMCP.Suite.Extension:GenMCP.Suite.Extension.tools/2,GenMCP.Suite.Extension.resources/2andGenMCP.Suite.Extension.prompts/2.- The
GenMCP.MCP.V2607.call_tool_result/1andGenMCP.MCP.V2607.read_resource_result/1option 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
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 withThis 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.