ConduitMCP serves two generations of MCP on the same endpoint:
| Era | Versions | How the version is chosen |
|---|---|---|
| Modern | 2026-07-28 | Every request carries its version and client capabilities in params._meta. No handshake, no sessions. |
| Legacy | 2025-11-25, 2025-06-18 | initialize negotiates a version once; later requests may share an Mcp-Session-Id. |
Your server module doesn't change. The same callbacks serve both eras, and the request body decides which era a request belongs to. This guide covers what differs on the wire and the modern-only features.
ConduitMcp.Protocol.supported_versions()
#=> ["2026-07-28", "2025-11-25", "2025-06-18"]
ConduitMcp.Protocol.modern_versions() #=> ["2026-07-28"]
ConduitMcp.Protocol.legacy_versions() #=> ["2025-11-25", "2025-06-18"]initialize only ever negotiates a legacy version. A modern client never
calls it.
Classifying a request
ConduitMcp.Era.classify/2 looks at two _meta keys:
pvisparams._meta["io.modelcontextprotocol/protocolVersion"]capsisparams._meta["io.modelcontextprotocol/clientCapabilities"]
_meta carries | method | result |
|---|---|---|
pv and caps | any (including initialize) | modern |
only one of pv and caps | any | -32602, HTTP 400 |
| neither | server/discover | modern (era-exempt, no version) |
| neither | anything else | legacy |
A request that carries only one of the two keys is rejected rather than
guessed at (-32602, HTTP 400). A modern request is also rejected with
-32602 when:
pvis not a string;capsis not an object;io.modelcontextprotocol/clientInfois present but not an object;io.modelcontextprotocol/logLevelis present but not an MCP logging level.
These error messages name the key, never its value.
Callbacks read the result from the conn:
def handle_call_tool(conn, "whoami", _params) do
case ConduitMcp.Ctx.from_conn(conn) do
%ConduitMcp.Ctx{era: :modern, version: v, client_info: info} ->
text("modern #{v}: #{inspect(info)}")
%ConduitMcp.Ctx{era: :legacy} ->
text("legacy client")
end
endclient_info is whatever the client says about itself. Never base a
security decision on it. The other ConduitMcp.Ctx fields are
client_capabilities, extensions, log_level, principal, request_id,
server_info and the W3C trace-context keys (traceparent, tracestate,
baggage). Invalid trace-context values are dropped, not rejected.
Mixed requests
The Mcp-Session-Id header never changes the era. A modern request that
sends one anyway is still modern, and its session is ignored. Cancellation
scoping then falls back to the principal, and after that to the client IP.
There is one exception. If the header names a live session that recorded a
principal, the modern request must come from that same principal. Otherwise
it is rejected with -32600 (HTTP 400), so a session id can't be carried
over to a different caller.
server/discover
server/discover tells a client which versions and capabilities a server
has, without a handshake. It is era-exempt: it is served as modern even
with no _meta, because a client may call it to find out which era the
server speaks.
{"jsonrpc": "2.0", "id": 1, "method": "server/discover"}{
"jsonrpc": "2.0",
"id": 1,
"result": {
"supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18"],
"capabilities": {
"tools": {"listChanged": true},
"resources": {"listChanged": true, "subscribe": true},
"logging": {}
},
"instructions": "Use search before fetch.",
"resultType": "complete",
"ttlMs": 60000,
"cacheScope": "private",
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "my-server", "version": "1.0.0"}}
}
}server/discover is authenticated like every other POST, because
capabilities can depend on who is asking. On the modern era, every
advertised tools, prompts and resources capability gets
listChanged: true, and resources also gets subscribe: true, because
every modern server answers subscriptions/listen (see
Subscriptions). logging is always declared, because
any tool can send log messages (see "Per-request logging"). instructions
is left out when there are none.
Instructions
instructions is free-text guidance for the client. It is returned by both
server/discover and the legacy initialize. Set it in one of three
places; the transport option wins:
use ConduitMcp.Server, instructions: "Use search before fetch."
use ConduitMcp.Endpoint, name: "My Server", version: "1.0.0",
instructions: "Use search before fetch."
{ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
instructions: "Use search before fetch."}The transport raises ArgumentError at init/1 if :instructions is not
a string.
Request headers
Over Streamable HTTP, a modern client copies selected body fields into HTTP
headers, so proxies and gateways can route without parsing JSON.
ConduitMcp.Plugs.HeaderValidation checks them against the body. A header
that disagrees with the body could send a request somewhere its body didn't
intend, so every failure is HTTP 400.
POST / HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
Mcp-Param-Region: eu-west
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "get_weather", "arguments": {"region": "eu-west"},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}}}}The checks run in this order:
MCP-Protocol-Versionis present, appears once, and equals the_metaversion. Otherwise-32020.- That version is one this server implements. Otherwise
-32022, withdata.supportedanddata.requested. Check 1 runs first, so a header that disagrees with the body is-32020even when neither version is supported. Mcp-Methodis present, appears once, and equals the bodymethod. Otherwise-32020.Mcp-Nameis required ontools/callandprompts/get(equal toparams.name), onresources/read(equal toparams.uri), and on the tasks extension'stasks/get,tasks/updateandtasks/cancel(equal toparams.taskId). Otherwise-32020.- On
tools/call, eachMcp-Param-{Name}header must match its argument. The header must be present when the argument has a non-null value, and absent when it doesn't. Otherwise-32020.
Error messages name the header, never its value. Nothing in this plug logs a header value.
A legacy request skips these checks, with one rule of its own: if it sends
an MCP-Protocol-Version header naming a version this server doesn't
support, it gets HTTP 400 with -32600 and data.supported. initialize
is exempt from that rule, and a request without the header is served.
Value encoding
Strings are sent as they are, integers in decimal, and booleans as
true/false. A string that isn't plain header-safe ASCII is sent as
=?base64?{Base64 of its UTF-8 bytes}?=. That covers bytes outside
0x20..0x7E, a leading or trailing space, and a string that could be
mistaken for that wrapper. ConduitMcp.Headers.encode_value/1 and
decode_value/1 implement the encoding. Integers compare numerically:
body values 42 and 42.0 both match the header "42".
Mcp-Param-*: the header: option
To have a tool argument mirrored into a header, give the param a header:
name. It publishes "x-mcp-header" on the property in tools/list, and
modern clients then send the header.
# DSL
tool "get_weather", "Weather by region" do
param :region, :string, "Region", required: true, header: "Region"
handle fn _conn, %{"region" => region} -> text("Sunny in #{region}") end
end
# Component
schema do
field :region, :string, "Region", required: true, header: "Region"
endIf the annotation is invalid, the build fails with a CompileError. The
param must be :string, :integer or :boolean, and reachable through
object properties only, never inside array items. The name must be an
RFC 9110 token, unique within the tool regardless of case. header: on a
prompt argument, or on a prompt or resource component, is also a compile
error.
Manual-mode servers write "x-mcp-header" into their own inputSchema.
HeaderValidation reads the annotations from the server's tools/list the
first time a tools/call comes in, and caches them per server module. A
tool with invalid annotations is logged at :error and left out of the
header checks. A server whose tool list changes at runtime calls
ConduitMcp.Plugs.HeaderValidation.invalidate/1 (see
Manual Mode).
Browsers and CORS
access-control-allow-headers has no wildcard that works for
credentialed requests. The defaults cover content-type,
authorization, mcp-session-id, mcp-protocol-version, mcp-method
and mcp-name. A browser client of a tool with header: params needs
those names added to :cors_headers:
{ConduitMcp.Transport.StreamableHTTP,
server_module: MyApp.MCPServer,
cors_origin: "https://app.example",
cors_headers:
"content-type, authorization, mcp-session-id, mcp-protocol-version, " <>
"mcp-method, mcp-name, mcp-param-region"}HTTP status codes
A modern response's status depends on the outcome, not always 200:
| Outcome | Status |
|---|---|
Result, or a method error such as an unknown tool's -32602 | 200 |
| Notification | 202, no body |
Any rejection before the method runs (Era.classify/2, HeaderValidation) | 400 |
-32020, -32021, -32022 | 400 |
-32601 (unknown method, including every legacy-only method) | 404 |
Insufficient scope under strategy: :oauth | 403 with an insufficient_scope challenge |
subscriptions/listen without text/event-stream in Accept | 406 |
subscriptions/listen when :max_streams is full | 503 |
ConduitMcp.Protocol.Modern.http_status/1 maps a response to its status.
The modern response echoes the request's version in mcp-protocol-version.
A legacy JSON-RPC response is sent at 200 (202 for a notification), except
for the 403 OAuth challenge. The legacy checks that run before dispatch
answer with their own status: the unsupported-version 400 and the session
400/404.
Authentication and HTTP rate limiting run before era classification, so
their 401 and 429 responses look the same in both eras. A modern request
rejected by classification or header validation is not counted by
:message_rate_limit.
Results and cache hints
Every modern result carries "resultType" ("complete",
"input_required" or "task") and the server's name and version in
_meta["io.modelcontextprotocol/serverInfo"].
A tools/call result's _meta is the tool's own _meta (e.g. MCP Apps UI
metadata) plus serverInfo; the request's _meta (progressToken,
logLevel, your own keys) is not echoed. On the legacy era the request's
_meta is still echoed into the result for backward compatibility, merged
under the tool's own keys, which always win.
Six results also carry cache hints: server/discover, tools/list,
prompts/list, resources/list, resources/templates/list and
resources/read. Each gets "ttlMs" and "cacheScope". The defaults come
from the application environment and are checked at boot:
config :conduit_mcp, cache_ttl_ms: 60_000, cache_scope: :privateA tool, prompt or resource can override them:
# DSL, inside a tool, prompt or resource block
tool "catalog", "Public catalog" do
cache ttl_ms: 5_000, scope: :public
handle fn _conn, _params -> text("...") end
end
# Component
use ConduitMcp.Component, type: :tool, description: "Public catalog",
cache: [ttl_ms: 5_000, scope: :public]The hints resolve as follows (ConduitMcp.CacheHints):
- A list result takes the minimum TTL across its items. It is
privateif any item is private or has ascope. A scoped item is never cached as public, even when the global default is:public. resources/readuses the hint of the resource that serves the URI: its exact static URI, otherwise the first matching template. It isprivatewhen that resource is scoped.server/discoveruses the defaults.
Declaring both scope and cache scope: :public on one item is a compile
error. Manual-mode servers get the defaults, with the scoped-means-private
rule still applied, and may define __cache_hints__/0 to declare
overrides. input_required and task results never carry cache hints.
Per-request logging
A modern client asks for log messages on one request at a time, with
_meta["io.modelcontextprotocol/logLevel"]; logging/setLevel belongs to
the legacy era. Without a logLevel, the server must not send
notifications/message for that request. ConduitMcp.Ctx.log?/2 applies
this rule, and ConduitMcp.Log.log/4 checks it for you. Every modern
server/discover declares the logging capability, which the spec requires
of a server that sends log messages; the legacy initialize declares it only
when the server module defines handle_set_log_level/2.
Streamed tool responses
A modern tools/call whose _meta carries a progressToken or a
logLevel may be answered as a Server-Sent Events stream, if the client's
Accept includes text/event-stream. The stream carries
notifications/progress and notifications/message before the result,
with :\r\n keep-alive comments in between.
tool "import", "Import rows" do
param :rows, :array, "Rows", required: true do
items :string
end
handle fn conn, %{"rows" => rows} ->
total = length(rows)
rows
|> Enum.with_index(1)
|> Enum.reduce_while(:ok, fn {row, done}, :ok ->
if ConduitMcp.Cancellation.cancelled?(conn) do
{:halt, :cancelled}
else
MyApp.Importer.import_row(row)
ConduitMcp.Progress.report(conn, done, total: total, message: "row #{done}")
{:cont, :ok}
end
end)
|> case do
:ok ->
ConduitMcp.Log.log(conn, :info, %{"imported" => total}, logger: "import")
text("imported #{total} rows")
:cancelled ->
error("Cancelled", ConduitMcp.Errors.request_cancelled())
end
end
endConduitMcp.Progress.report/3takes the options:totaland:message.ConduitMcp.Log.log/4takes a level as an atom or a string, any JSON-encodabledata, and the option:logger. It sends only when the level is at least the requestedlogLevel. The client receivesdataas it is, so never log secrets.- Both return
:okand send nothing when there is no stream to send on: noprogressToken(or nologLevel), a legacy request, a client that didn't accepttext/event-stream, or a call made throughConduitMcp.Handler.handle_request/3. Tools can call them unconditionally.
How the stream behaves:
- The tool runs in a linked task. The request process relays its notifications.
- The stream opens late. It opens on the first notification or keep-alive tick. A result that arrives first goes out as plain JSON.
- At the cap. Once
:max_streamsis reached, a response that would open a stream is sent as plain JSON instead, without its notifications. The client is never refused. - Disconnect is cancellation. The modern HTTP transport has no
notifications/cancelled; a client cancels by closing the stream. When a write fails, the request is marked cancelled andConduitMcp.Cancellation.cancelled?/1turnstruefor the tool. A tool that hasn't returned within:stream_cancel_timeoutis killed. - A raising tool produces a
-32603error event, then the stream closes.
| Transport option | Default | Meaning |
|---|---|---|
:keep_alive_interval | 15_000 | ms between :\r\n keep-alive comments |
:max_streams | 1_000 | concurrent streams, including subscriptions/listen |
:max_stream_lifetime | 1 hour | after this, the request is cancelled; the response is -32603 unless the tool returns within :stream_cancel_timeout |
:stream_cancel_timeout | 5_000 | ms a cancelled tool gets to return before it is killed |
These options must be positive integers, or init/1 raises
ArgumentError.
What the modern era leaves out
| Legacy | Modern |
|---|---|
initialize / notifications/initialized | none; server/discover if the client wants it |
ping | none (-32601, HTTP 404) |
logging/setLevel | per-request logLevel |
resources/subscribe / unsubscribe | subscriptions/listen (Subscriptions) |
Mcp-Session-Id sessions | none; never minted, required or looked up |
notifications/cancelled | closing the response stream; every modern notification is dropped |
| Server-to-client sampling, elicitation, roots | input_required results (MRTR) |
tasks/result, tasks/list (ConduitMcp.Tasks) | the tasks extension (Tasks Extension) |
The modern routing table has server/discover, tools/list, tools/call,
resources/list, resources/templates/list, resources/read,
prompts/list, prompts/get, completion/complete and
subscriptions/listen. tasks/get, tasks/update and tasks/cancel are
added while the tasks extension is enabled. Every other method returns
-32601.
GET and DELETE
GET and DELETE carry no _meta, so they keep their legacy behaviour:
GET /withAccept: text/event-streamreturns 405 with anallowheader, because this server offers no standalone server-to-client stream. Any otherGET /returns a JSON info document.DELETE /ends the legacy session named byMcp-Session-Id(200). An unknown id is 404 and a missing one is 400. With sessions off it is 405.
The legacy ConduitMcp.Transport.SSE answers modern requests on
POST /message, but GET /sse is legacy-only. Point modern clients at
ConduitMcp.Transport.StreamableHTTP.
Error codes
| Code | Function | Meaning |
|---|---|---|
-32700 | Errors.parse_error/0 | Invalid JSON |
-32600 | Errors.invalid_request/0 | Not a valid request; also a session bound to another principal, an unsupported legacy MCP-Protocol-Version header (400), and a listen without text/event-stream (406) |
-32601 | Errors.method_not_found/0 | Unknown method; HTTP 404 on the modern era |
-32602 | Errors.invalid_params/0 | Invalid params; also invalid modern _meta (400), an invalid requestState, and a missing resource on the modern era |
-32603 | Errors.internal_error/0 | Internal error |
-32000 | Errors.server_error/0 | Generic server error; also insufficient scope and the listen 503 body |
-32002 | Protocol.Legacy.resource_not_found/0 | Resource not found, legacy era only |
-32004 | Errors.task_not_ready/0 | Legacy tasks/result before the task finished |
-32020 | Errors.header_mismatch/0 | A header is missing, duplicated, malformed or disagrees with the body (400) |
-32021 | Errors.missing_required_client_capability/0 | The request needs a client capability it didn't declare; data.requiredCapabilities (400) |
-32022 | Errors.unsupported_protocol_version/0 | Modern _meta version not implemented; data.supported, data.requested (400) |
-32800 | Errors.request_cancelled/0 | Request cancelled |
Errors.modern_task_not_ready/0 (-31004) is the modern counterpart of
-32004.
Resource not found
The two eras use different codes for a missing resource. 2025-11-25 and
earlier use -32002, and 2026-07-28 forbids -32002 and uses -32602.
Callbacks don't need to know which era they're serving. Keep returning the
legacy code:
def handle_read_resource(_conn, uri) do
{:error,
%{"code" => ConduitMcp.Protocol.Legacy.resource_not_found(),
"message" => "Resource not found: #{uri}"}}
endConduitMcp.Protocol.Modern rewrites -32002 to -32602 on every modern
response, so a legacy client sees -32002 and a modern client sees
-32602. ConduitMcp.Protocol.Modern.resource_not_found/0 returns
-32602.