ConduitMCP serves two generations of MCP on the same endpoint:

EraVersionsHow the version is chosen
Modern2026-07-28Every request carries its version and client capabilities in params._meta. No handshake, no sessions.
Legacy2025-11-25, 2025-06-18initialize 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:

  • pv is params._meta["io.modelcontextprotocol/protocolVersion"]
  • caps is params._meta["io.modelcontextprotocol/clientCapabilities"]
_meta carriesmethodresult
pv and capsany (including initialize)modern
only one of pv and capsany-32602, HTTP 400
neitherserver/discovermodern (era-exempt, no version)
neitheranything elselegacy

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:

  • pv is not a string;
  • caps is not an object;
  • io.modelcontextprotocol/clientInfo is present but not an object;
  • io.modelcontextprotocol/logLevel is 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
end

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

  1. MCP-Protocol-Version is present, appears once, and equals the _meta version. Otherwise -32020.
  2. That version is one this server implements. Otherwise -32022, with data.supported and data.requested. Check 1 runs first, so a header that disagrees with the body is -32020 even when neither version is supported.
  3. Mcp-Method is present, appears once, and equals the body method. Otherwise -32020.
  4. Mcp-Name is required on tools/call and prompts/get (equal to params.name), on resources/read (equal to params.uri), and on the tasks extension's tasks/get, tasks/update and tasks/cancel (equal to params.taskId). Otherwise -32020.
  5. On tools/call, each Mcp-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"
end

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

OutcomeStatus
Result, or a method error such as an unknown tool's -32602200
Notification202, no body
Any rejection before the method runs (Era.classify/2, HeaderValidation)400
-32020, -32021, -32022400
-32601 (unknown method, including every legacy-only method)404
Insufficient scope under strategy: :oauth403 with an insufficient_scope challenge
subscriptions/listen without text/event-stream in Accept406
subscriptions/listen when :max_streams is full503

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

A 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 private if any item is private or has a scope. A scoped item is never cached as public, even when the global default is :public.
  • resources/read uses the hint of the resource that serves the URI: its exact static URI, otherwise the first matching template. It is private when that resource is scoped.
  • server/discover uses 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
end
  • ConduitMcp.Progress.report/3 takes the options :total and :message.
  • ConduitMcp.Log.log/4 takes a level as an atom or a string, any JSON-encodable data, and the option :logger. It sends only when the level is at least the requested logLevel. The client receives data as it is, so never log secrets.
  • Both return :ok and send nothing when there is no stream to send on: no progressToken (or no logLevel), a legacy request, a client that didn't accept text/event-stream, or a call made through ConduitMcp.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_streams is 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 and ConduitMcp.Cancellation.cancelled?/1 turns true for the tool. A tool that hasn't returned within :stream_cancel_timeout is killed.
  • A raising tool produces a -32603 error event, then the stream closes.
Transport optionDefaultMeaning
:keep_alive_interval15_000ms between :\r\n keep-alive comments
:max_streams1_000concurrent streams, including subscriptions/listen
:max_stream_lifetime1 hourafter this, the request is cancelled; the response is -32603 unless the tool returns within :stream_cancel_timeout
:stream_cancel_timeout5_000ms 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

LegacyModern
initialize / notifications/initializednone; server/discover if the client wants it
pingnone (-32601, HTTP 404)
logging/setLevelper-request logLevel
resources/subscribe / unsubscribesubscriptions/listen (Subscriptions)
Mcp-Session-Id sessionsnone; never minted, required or looked up
notifications/cancelledclosing the response stream; every modern notification is dropped
Server-to-client sampling, elicitation, rootsinput_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 / with Accept: text/event-stream returns 405 with an allow header, because this server offers no standalone server-to-client stream. Any other GET / returns a JSON info document.
  • DELETE / ends the legacy session named by Mcp-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

CodeFunctionMeaning
-32700Errors.parse_error/0Invalid JSON
-32600Errors.invalid_request/0Not 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)
-32601Errors.method_not_found/0Unknown method; HTTP 404 on the modern era
-32602Errors.invalid_params/0Invalid params; also invalid modern _meta (400), an invalid requestState, and a missing resource on the modern era
-32603Errors.internal_error/0Internal error
-32000Errors.server_error/0Generic server error; also insufficient scope and the listen 503 body
-32002Protocol.Legacy.resource_not_found/0Resource not found, legacy era only
-32004Errors.task_not_ready/0Legacy tasks/result before the task finished
-32020Errors.header_mismatch/0A header is missing, duplicated, malformed or disagrees with the body (400)
-32021Errors.missing_required_client_capability/0The request needs a client capability it didn't declare; data.requiredCapabilities (400)
-32022Errors.unsupported_protocol_version/0Modern _meta version not implemented; data.supported, data.requested (400)
-32800Errors.request_cancelled/0Request 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}"}}
end

ConduitMcp.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.