Routes JSON-RPC 2.0 MCP requests to a server module's behaviour callbacks.
Transports decode the request body, hand the resulting map to this module, and serialize what comes back. The handler itself is stateless — it does not own the server module, the connection, or any process state. Each request is independent and can run concurrently in its own Bandit process.
Two entry points:
dispatch/3— the transport contract: classifies the request's protocol era (unless given aConduitMcp.Ctx) and returns{:reply, response},{:stream, spec}or:noreply.handle_request/3— the map-returning wrapper overdispatch/3, kept for manual-mode callers.
Protocol eras
Every request is classified by ConduitMcp.Era.classify/2:
- Modern (
2026-07-28) — the request'sparams._metacarriesio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilities, or the method isserver/discover. Routed methods:server/discover,tools/list,tools/call,resources/list,resources/templates/list,resources/read,prompts/list,prompts/get,completion/completeandsubscriptions/listen(a long-lived stream; seeConduitMcp.Subscriptions). Results carryresultType,serverInfo_metaand (on cacheable results)ttlMs/cacheScope; seeConduitMcp.Protocol.Modern. - Legacy (
2025-11-25,2025-06-18) — everything else. Routed methods:initialize,ping,tools/list,tools/call,resources/list,resources/templates/list,resources/read,resources/subscribe,resources/unsubscribe,prompts/list,prompts/get,completion/complete,logging/setLevel,tasks/get,tasks/cancel,tasks/result,tasks/list, and the notificationsnotifications/initializedandnotifications/cancelled.
Unknown methods return -32601 Method not found in both eras — on the
modern path that includes every legacy-only method (initialize, ping,
…), answered with HTTP 404 (ConduitMcp.Protocol.Modern.http_status/1).
Unknown notifications are logged and dropped (per JSON-RPC notification
semantics) — but a known legacy notification that is malformed is
answered with an error whose id is null, rather than dropped. See
handle_request/3. The modern era defines no client notifications over
HTTP, so every modern notification is dropped.
The callbacks receive a Plug.Conn in both eras; the request's
ConduitMcp.Ctx is in it (ConduitMcp.Ctx.from_conn/1).
Capability detection
When the server module defines __capabilities__/0 (typically generated
by ConduitMcp.Endpoint), it is consulted for the initialize reply and
the server/discover result. Otherwise the base
tools/resources/prompts set is used. Capability flags for optional
features (completions, logging, resources.subscribe) are overlaid at
runtime based on which callbacks the server exports. See
ConduitMcp.Capabilities.build/2.
Telemetry
Emits these events:
[:conduit_mcp, :request, :stop]— every request, with%{duration: <native>}and metadata%{method, server_module, status, era, protocol_version, traceparent, tracestate, baggage}— except asubscriptions/listenthat opened a stream, which is measured by[:conduit_mcp, :subscription, :start | :stop]instead (emitted by the transport): its duration is the stream's lifetime, not a latency.[:conduit_mcp, :tool, :execute]— pertools/call.[:conduit_mcp, :resource, :read]— perresources/read.[:conduit_mcp, :prompt, :get]— perprompts/get.[:conduit_mcp, :mrtr, :input_required]— per modernInputRequiredResultsent.[:conduit_mcp, :request, :cancelled]— when anotifications/cancelledfor a request in flight is recorded.
Multi round-trip requests
On the modern path tools/call, resources/read and prompts/get
callbacks may return {:input_required, input_requests, state}; a retry's
requestState is verified before the callback runs, and the restored state
arrives in conn.assigns[:mcp_input]. See ConduitMcp.Server and
ConduitMcp.MRTR. On the legacy path that return is answered with
-32603 ("MRTR input requests require protocol 2026-07-28").
Tasks extension
With ConduitMcp.Ext.Tasks enabled, the modern era also routes
tasks/get, tasks/update and tasks/cancel, and a tools/call
callback may return {:task, attrs}; the client must declare
io.modelcontextprotocol/tasks in the request's capability extensions
(-32021 otherwise) and gets a CreateTaskResult. Anywhere else —
other methods, the legacy era, the extension disabled — that return is
answered with -32603.
Cancellation
The handler stashes the request id into conn.assigns[:mcp_request_id]
before dispatch, marks the request in flight with
ConduitMcp.Cancellation.track/2, and uses try/after to untrack it and
clear any cancellation flag after the response is produced. Tools poll
ConduitMcp.Cancellation.cancelled?(conn) for cooperative aborts.
notifications/cancelled is recorded only for a request in flight in the
caller's scope; one naming an unknown or completed request is ignored. A
modern request never has a session, so its scope is its principal, else
its client IP.
Layout
This module is the public entry point only. The work is split into internal (undocumented) modules:
Handler.Legacy/Handler.Modern— each era's request validation and routing table;Handler.Legacyalso has the lifecycle, logging and subscription methods only that era has.Handler.Pipeline— the per-request wrapper:request.stoptelemetry, in-flight tracking, and the rescue that turns a raise into-32603.- Method modules (
Handler.Tools,.Resources,.Prompts,.Completion,.LegacyTasks, with shared helpers in.Common) return an era-neutralresult/0and never build JSON-RPC maps. The three MRTR methods run their callback throughHandler.MRTR, which opensrequestState, gates and seals{:input_required, ...}, and refuses it on the legacy era. ConduitMcp.Protocol.Legacy/ConduitMcp.Protocol.Modernshape aresult/0into the JSON-RPC response of their era.
Summary
Types
What a transport does with a message
The era-neutral outcome of a method: a result map, an input-required
round, or an error code, message and optional data member. Shaped into a
protocol response by the era's response shaper
(ConduitMcp.Protocol.Legacy or ConduitMcp.Protocol.Modern).
Functions
Classifies and handles a decoded JSON-RPC message.
Handles an MCP request and returns a JSON-RPC response. Emits telemetry events for monitoring and metrics.
Every method this handler routes, in either era, mapped to its internal name.
The methods one era routes (:legacy includes its notifications).
Types
@type reply() :: {:reply, map()} | {:stream, ConduitMcp.StreamSpec.t()} | :noreply
What a transport does with a message:
{:reply, response}— send the JSON-RPCresponsemap. For a modern request the HTTP status isConduitMcp.Protocol.Modern.http_status/1of it.{:stream, spec}— aConduitMcp.StreamSpec: a response the transport may send as a Server-Sent Events stream, relaying the notifications the work emits ahead of it. Returned for a moderntools/callwhose_metacarries aprogressTokenor alogLevel(kind:response), and for a modernsubscriptions/listenwhose filter parsed (kind:listen).:noreply— a notification: nothing to send.
@type result() :: {:ok, map()} | {:input_required, %{optional(String.t()) => map()}, term()} | {:task, map()} | {:error, integer(), String.t(), map() | nil}
The era-neutral outcome of a method: a result map, an input-required
round, or an error code, message and optional data member. Shaped into a
protocol response by the era's response shaper
(ConduitMcp.Protocol.Legacy or ConduitMcp.Protocol.Modern).
{:input_required, input_requests, state} is what an MRTR callback
returned; by the time it reaches ConduitMcp.Protocol.Modern its state
has been sealed into the requestState token. Only the modern era shapes
it: the legacy era answers it with -32603 before shaping.
{:task, task} is a ConduitMcp.Ext.Tasks task created for a modern
tools/call (its wire Task map), shaped into a CreateTaskResult.
Functions
@spec dispatch(map(), module(), ConduitMcp.Ctx.t() | Plug.Conn.t() | map()) :: reply()
Classifies and handles a decoded JSON-RPC message.
The third argument is either the request's ConduitMcp.Ctx — what a
transport passes once it has run ConduitMcp.Era.classify/2 itself (and,
for a modern request, ConduitMcp.Plugs.HeaderValidation) — or the
request's Plug.Conn, in which case the request is classified here. A
context already stored on that conn is ignored: the conn may belong to an
outer request.
A classification failure (invalid modern _meta, or a mixed request whose
session is bound to another principal) is answered with a modern error
response; on HTTP such a rejection is a 400, whatever its code (see
ConduitMcp.Era).
@spec handle_request(map(), module(), Plug.Conn.t() | map()) :: map() | :ok
Handles an MCP request and returns a JSON-RPC response. Emits telemetry events for monitoring and metrics.
A wrapper over dispatch/3 that classifies the request's era itself.
Returns a response map for a request, and :ok for a notification that was
handled or ignored, or for a JSON-RPC response: the server sends no
requests of its own, so a response answers nothing and is dropped.
ConduitMcp.Transport.Shared.dispatch_post/2 answers :ok with
202 Accepted and no body. A {:stream, spec} dispatch of kind
:response is run to completion in the calling process
(ConduitMcp.StreamSpec.run/1): its notifications are dropped and its
final response is returned. One of kind :listen — a modern
subscriptions/listen — is answered -32601: a long-lived notification
stream needs a transport that can hold it open, which a map return
cannot.
A notification has no id to correlate, so JSON-RPC says a failed one is
dropped. Legacy notifications/cancelled is the exception: in these cases
it returns an error map with "id" => nil instead of :ok, because
reporting the client's own mistake back to it is a lesser deviation than a
silent drop:
paramsis present but not an object →-32602invalid params.requestIdis present and neither an integer nor a string of at most 256 bytes →-32602invalid params (a missing ornullrequestIdis ignored::ok). This is checked whether or not the request is in flight.- the request is in flight and the caller's scope is at its cancellation
quota (
{:error, :cancellation_limit_reached}fromConduitMcp.Cancellation.cancel/3) →-32000server error.
A notifications/cancelled for a request that is not in flight in the
caller's scope returns :ok and records nothing.
A transport that pattern-matches :ok for every notification must handle
the map too; both of ours do, via
ConduitMcp.Transport.Shared.dispatch_post/2, which sends it with status
200.
Every method this handler routes, in either era, mapped to its internal name.
Published as ConduitMcp.Protocol.methods/0.
@spec methods(ConduitMcp.Ctx.era()) :: %{optional(String.t()) => atom()}
The methods one era routes (:legacy includes its notifications).