ConduitMcp.Handler (ConduitMCP v0.11.0)

Copy Markdown View Source

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 a ConduitMcp.Ctx) and returns {:reply, response}, {:stream, spec} or :noreply.
  • handle_request/3 — the map-returning wrapper over dispatch/3, kept for manual-mode callers.

Protocol eras

Every request is classified by ConduitMcp.Era.classify/2:

  • Modern (2026-07-28) — the request's params._meta carries io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, or the method is server/discover. Routed methods: server/discover, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, completion/complete and subscriptions/listen (a long-lived stream; see ConduitMcp.Subscriptions). Results carry resultType, serverInfo _meta and (on cacheable results) ttlMs/cacheScope; see ConduitMcp.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 notifications notifications/initialized and notifications/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 a subscriptions/listen that 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] — per tools/call.
  • [:conduit_mcp, :resource, :read] — per resources/read.
  • [:conduit_mcp, :prompt, :get] — per prompts/get.
  • [:conduit_mcp, :mrtr, :input_required] — per modern InputRequiredResult sent.
  • [:conduit_mcp, :request, :cancelled] — when a notifications/cancelled for 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.Legacy also has the lifecycle, logging and subscription methods only that era has.
  • Handler.Pipeline — the per-request wrapper: request.stop telemetry, 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-neutral result/0 and never build JSON-RPC maps. The three MRTR methods run their callback through Handler.MRTR, which opens requestState, gates and seals {:input_required, ...}, and refuses it on the legacy era.
  • ConduitMcp.Protocol.Legacy / ConduitMcp.Protocol.Modern shape a result/0 into 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

reply()

@type reply() :: {:reply, map()} | {:stream, ConduitMcp.StreamSpec.t()} | :noreply

What a transport does with a message:

  • {:reply, response} — send the JSON-RPC response map. For a modern request the HTTP status is ConduitMcp.Protocol.Modern.http_status/1 of it.
  • {:stream, spec} — a ConduitMcp.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 modern tools/call whose _meta carries a progressToken or a logLevel (kind :response), and for a modern subscriptions/listen whose filter parsed (kind :listen).
  • :noreply — a notification: nothing to send.

result()

@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

dispatch(request, server_module, ctx)

@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).

handle_request(request, server_module, conn \\ %Plug.Conn{})

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

  • params is present but not an object → -32602 invalid params.
  • requestId is present and neither an integer nor a string of at most 256 bytes → -32602 invalid params (a missing or null requestId is 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} from ConduitMcp.Cancellation.cancel/3) → -32000 server 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.

methods()

@spec methods() :: %{optional(String.t()) => atom()}

Every method this handler routes, in either era, mapped to its internal name.

Published as ConduitMcp.Protocol.methods/0.

methods(atom)

@spec methods(ConduitMcp.Ctx.era()) :: %{optional(String.t()) => atom()}

The methods one era routes (:legacy includes its notifications).