Protocol Versions

View Source

barrel_mcp speaks every released revision of MCP, from 2024-11-05 through 2026-07-28. Those revisions fall into two eras that differ in how a peer says who it is, and a server decides which one it is answering per request rather than per deployment. You need this page when you are pinning a client to a revision, when a request comes back with -32020, -32021 or -32022, or when you are writing a tool handler that has to work against both eras.

The two eras

Legacy revisions (2025-11-25 and earlier) open with an initialize handshake. The server issues a session id, and every later request carries it. Protocol version, client capabilities and client identity are established once and remembered.

Modern revisions (2026-07-28 and later) have no handshake and no session. Every request carries its own protocol version, capabilities and identity in params._meta.

LegacyModern
Opens withinitializenothing
Version comes fromthe handshake_meta on each request
SessionMcp-Session-Idnone
Server to clientrequests over SSEinputRequests in the result
Subscriptionsresources/subscribe + GET SSEsubscriptions/listen
Resumable streamLast-Event-IDno

A request is modern when params._meta carries io.modelcontextprotocol/protocolVersion, and legacy otherwise. initialize is always legacy.

Serve both eras

Nothing to do. One listener answers both, and the eras do not see each other:

{ok, _} = barrel_mcp:start_http_stream(#{port => 8080}).

A legacy client holding a session and an open GET stream, and a modern client holding a subscriptions/listen stream, can run against that port at the same time. Registry changes reach both.

Pin a client to a revision

protocol_version defaults to auto, which sends server/discover and falls back to the initialize handshake if the server does not answer it:

{ok, Pid} = barrel_mcp:start_client(my_client, #{
    transport => {http, #{url => <<"http://localhost:8080/mcp">>}},
    protocol_version => auto,
    probe_timeout => 5000
}).

Pin a revision to skip the probe:

%% Modern only. No probe, no handshake.
#{protocol_version => <<"2026-07-28">>}

%% Legacy only. Straight to initialize.
#{protocol_version => <<"2025-11-25">>}

Read back what was negotiated:

{ok, Version} = barrel_mcp_client:protocol_version(Pid).

Pin when you know the server, since the probe costs a round trip against a legacy one. Leave it on auto when you do not.

Choose what the server advertises

On -32022 the server names the revisions a client may retry with. By default that is the modern list only:

%% sys.config
{barrel_mcp, [{advertise_versions, modern}]}

Set all to advertise both eras. Be aware of what that means: a client that picks a legacy revision off the list and names it in per-request _meta is rejected again, because a legacy revision cannot be reached that way. It has to drop to the handshake. Legacy clients never see this list, and initialize works either way.

Compare revisions in your own code

Revisions are an enumerated set, not an ordered scalar. They happen to be date-shaped today, so they happen to sort, but comparing the binaries makes any string from a peer you do not recognise look newer than everything you know: <<"zzz">> > <<"2026-07-28">> is true and means nothing.

true  = barrel_mcp_version:is_at_least(<<"2026-07-28">>, <<"2025-11-25">>),
false = barrel_mcp_version:is_at_least(<<"zzz">>, <<"2024-11-05">>),
modern = barrel_mcp_version:era(<<"2026-07-28">>),
[<<"2026-07-28">> | _] = barrel_mcp_version:all().

An unrecognised revision satisfies no minimum. An unrecognised minimum raises, since that is a bug where it is written.

Methods that exist in one era only

MethodLegacyModern
initializeyesno
pingyesno
logging/setLevelyesno
resources/subscribe / unsubscribeyesno
tasks/list, tasks/resultyesno
subscriptions/listennoyes
tasks/updatenoyes
server/discoveryesyes

Everything else (tools/*, resources/read, prompts/*, completion/complete, tasks/get, tasks/cancel) works in both.

Calling one from the wrong era is -32601, as an unknown method: it does not exist there, and the peer that asked cannot use it if it did.

The client answers before the round trip rather than letting the server reject it, so the verbs stay callable and tell you why:

{error, {unsupported, <<"ping">>}} = barrel_mcp_client:ping(Pid).

subscribe/2 and unsubscribe/2 are the exception. They keep their signatures and their meaning, running over subscriptions/listen in modern mode and resources/subscribe in legacy. notify_roots_list_changed/1 is a cast and is dropped in modern mode, where Roots no longer exists. Ping keepalive turns itself off, since a configured cadence would only produce method-not-found on a timer.

Write a handler that works in both

Handlers do not choose an era. Arity-2 handlers get a Ctx and ask it what the client can do:

my_tool(Args, Ctx) ->
    case barrel_mcp:client_supports(Ctx, elicitation) of
        true  -> ask_the_user(Args, Ctx);
        false -> use_a_default(Args)
    end.

Asking for input works the same in both eras from the handler's side. What differs is the wire: legacy sends a server-to-client request over the session stream, modern returns the question in the result and waits for the client to retry. See the Features guide for the full shape.

Errors this revision added

CodeMeaningWhat to do
-32020MCP-Protocol-Version or an Mcp-* header disagrees with the bodyRe-fetch tools/list and retry once; the tool's header annotations changed
-32021The server needs a capability you did not declareDeclare it in _meta, or accept the degraded path
-32022The revision you named is not servedRetry with one from data.supported

-32020 through -32099 are reserved for the specification. Do not emit a code in that range that the spec does not define.

Where the era is decided

Read this before adding a revision or changing what one era serves. The era is one function and one classification, but it is consulted in many places; this is the list to walk.

Definition: barrel_mcp_version:era/1 maps a revision to modern or legacy from ?MCP_MODERN_VERSIONS and ?MCP_LEGACY_VERSIONS in include/barrel_mcp.hrl. Adding a revision starts there.

Classification: barrel_mcp_ctx:from_request/2 builds the request context and decides the era once, from the body's _meta and the transport's version (classify/3, version_of/3, capabilities_of/3). Everything downstream reads barrel_mcp_ctx:era/1 or barrel_mcp_ctx:is_modern/1; nothing re-derives it.

The fork per transport:

  • Streamable HTTP: barrel_mcp_http_engine:stream_post_request, after decode and before any session lookup, so a modern request never touches the session machinery.
  • The 2024-11-05 pair: always legacy (legacy_transport_version/2).
  • stdio: barrel_mcp_stdio:bind_session attaches the SSE channel to the session for the legacy era only.
  • Client: barrel_mcp_client chooses between server/discover and initialize from the requested version.

Branch sites, by module. Each one behaves differently per era and needs a look when an era's rules change:

modulefunctions
barrel_mcp_protocolserves/2 and serves_in_era/2 (which methods exist), advertised_versions/0, check_version/1, resource_not_found_code/1, internal_error_code/1, with_cache_hints/3, finalize/2, seal_input_required/6, with_tasks_extension/3, task_owner/1, tasks_enabled/1, create_task_result/3, cancel_task_response/4, with_execution/3, drive_async_plan/4, read_task_for/3, the tasks/get handler
barrel_mcp_http_enginestream_post_request/6, handle_async_tool_call/7 (the escalate mode), streams_notifications/4, cancels_on_disconnect/1, request_log_level/1, internal_error_code/1, start_task_worker/3
barrel_mcp_tasksget/2 (defaults to legacy), the ttl versus ttlMs rendering, task_to_map
barrel_mcp_task_relayescalate/6 renders the task for the caller's era
barrel_mcp_elicitationwith_elicitation_id/3 (modern only)
barrel_mcp_ctxclassify/3, validate/1, validate_version/1

The tests that pin the split: test/barrel_mcp_dual_era_SUITE.erl (both eras on one listener), test/barrel_mcp_protocol_tests.erl (initialize per legacy revision), and the conformance runner at each revision (test/barrel_mcp_conformance_SUITE.erl).

Notes

  • Nothing was removed in 3.0. Every legacy path still works, and a 2.3.0 deployment upgrades without configuration changes.
  • The HTTP+SSE transport from 2024-11-05 is off unless you configure it. Pass sse_path and sse_message_path to start_http_stream/1 to serve it alongside Streamable HTTP on the same listener; without them a legacy client reaches you over Streamable HTTP carrying an older revision.
  • barrel_mcp_version is the only place that orders revisions. Nothing else in the library compares version binaries, and neither should your code.