The single implementation of everything ConduitMcp.Transport.StreamableHTTP
and ConduitMcp.Transport.SSE have in common.
Keeping one implementation is what keeps the transports from diverging: a
feature added to one router (the :oauth auth strategy,
/.well-known/oauth-protected-resource, the mcp-protocol-version header)
exists on the other by construction.
use ConduitMcp.Transport.Shared therefore generates, in both routers:
- the
Plug.Routerpipeline up to the transport-specific plugs, init/1andcall/2,- the shared plug functions (
add_cors_headers,authenticate,rate_limit,classify_era,message_rate_limit),
and shared_routes/0 generates the shared routes (options _,
get "/health", get "/.well-known/oauth-protected-resource", match _).
No function body exists in both transports.
Protocol eras
classify_era runs on POST and DELETE after authenticate and
rate_limit, and before message_rate_limit, so a request it rejects is
never counted against the client. It runs
ConduitMcp.Plugs.HeaderValidation, which classifies the body
(ConduitMcp.Era.classify/2): a modern request has its
ConduitMcp.Ctx stored and its request-metadata headers validated (any
rejection is HTTP 400); a legacy request whose MCP-Protocol-Version
header names an unsupported version is answered 400 (initialize
excepted). dispatch_post/2 then dispatches each era its own way.
Plugs are resolved once
init/1 resolves :auth, :rate_limit and :message_rate_limit into
{module, initialised_opts} pairs, so call/2 is mod.call(conn, opts)
and no plug's init/1 runs per request. It also validates the shape of
:cors_origin and :allowed_origins, so a misconfiguration raises
ArgumentError at boot rather than failing on every request.
ConduitMcp.Plugs.OAuth's init/1 in particular is where an unusable OAuth
configuration is reported as a configuration error rather than a 401.
Callbacks in transport options must be remote captures
Plug.Router.forward/2 runs the transport's init/1 at compile time and
escapes the result into the router module. An anonymous function
(fn conn -> ... end) or a local capture in the options — for example a
rate limiter's :key_func — cannot be escaped, so the router fails to
compile. Use a remote capture such as &MyApp.RateKeys.by_user/1.
Deliberate asymmetries
Anything the two transports genuinely do differently is listed in their own moduledocs with a reason, so that no asymmetry is accidental.
Summary
Types
A plug resolved at init/1 time: the module and its initialised options.
Functions
Generates the shared Plug.Router pipeline, init/1, call/2 and the
shared plug functions.
The shared JSON-RPC POST body dispatch.
Reads a use ConduitMcp.Endpoint server's compile-time config, which both
transports use as defaults for options not passed explicitly.
Validates and resolves transport options once, at init/1.
Sets mcp-protocol-version on the response to request response_map.
Resolves an auth configuration into {module, initialised_opts}.
Generates the routes both transports serve: the CORS preflight catch-all,
GET /health, the RFC 9728 protected-resource metadata endpoint, and the
404 catch-all.
Warns once per boot (from init/1) when no Origin policy was chosen at all.
Types
Functions
Generates the shared Plug.Router pipeline, init/1, call/2 and the
shared plug functions.
Options:
:extra_plugs— plug names to insert between:message_rate_limitand:dispatch(StreamableHTTP uses this for:validate_session).
@spec dispatch_post(Plug.Conn.t(), (Plug.Conn.t(), map() -> {:ok, Plug.Conn.t()} | {:error, Plug.Conn.t()})) :: Plug.Conn.t()
The shared JSON-RPC POST body dispatch.
A notification or a JSON-RPC response is answered 202 Accepted with no
body. A request is answered with its JSON-RPC response and an
mcp-protocol-version header (see put_protocol_version_header/2).
A legacy request's response is sent at HTTP 200, except that under
strategy: :oauth an insufficient-scope error is answered 403 with an
insufficient_scope Bearer challenge (ConduitMcp.Plugs.OAuth.forbidden/4),
so the client can step up.
A modern request (one the :classify_era plug stored a modern
ConduitMcp.Ctx for) is dispatched with ConduitMcp.Handler.dispatch/3.
Its response is sent at ConduitMcp.Protocol.Modern.http_status/1 (400
for -32020/-32021/-32022, 404 for an unknown method, else 200), with
the same 403 challenge. A {:stream, spec} dispatch of kind :response —
a tools/call asking for progress or log notifications — is answered as a
Server-Sent Events stream when the request's Accept includes
text/event-stream (see ConduitMcp.StreamSpec), otherwise as plain JSON
with its notifications dropped. One of kind :listen — a
subscriptions/listen — is served by ConduitMcp.Subscriptions as a
long-lived stream; without text/event-stream in Accept it is answered
406 with a -32600 error, since it has no JSON form. prepare never
runs for a modern request: the modern era has no sessions to mint.
prepare runs after a successful legacy response map is produced and
before it is sent, so a transport can attach transport-specific headers or
state. It returns {:ok, conn} to continue, or {:error, conn} when it
has already sent a response of its own.
Reads a use ConduitMcp.Endpoint server's compile-time config, which both
transports use as defaults for options not passed explicitly.
Validates and resolves transport options once, at init/1.
Returns the options with :auth_config, :auth_plug, :rate_limit_plug,
:message_rate_limit_plug and :shared_private added. :shared_private
also carries the transport's own __transport_private__/1 entries, so an
invalid transport-specific option raises here too.
@spec put_protocol_version_header(Plug.Conn.t(), map()) :: Plug.Conn.t()
Sets mcp-protocol-version on the response to request response_map.
A modern request is answered with the version its _meta named (already
validated by ConduitMcp.Plugs.HeaderValidation). Otherwise the value is
the version this connection negotiated, most specific source first: an
initialize result's own protocolVersion; then the version recorded on
the request's session; then the request's mcp-protocol-version header;
then ConduitMcp.Protocol.protocol_version/0. A session or header value is
used only when it is in ConduitMcp.Protocol.supported_versions/0, so
client input is never echoed unvalidated.
@spec resolve_auth_plug(keyword() | nil) :: resolved_plug()
Resolves an auth configuration into {module, initialised_opts}.
strategy: :oauth needs ConduitMcp.Plugs.OAuth, which is compiled only
when :joken was available when :conduit_mcp itself was built. When it is
missing this raises ConduitMcp.OptionalDependencyError naming both the
dependency and mix deps.compile conduit_mcp --force, rather than letting
the request fall through to ConduitMcp.Plugs.Auth's catch-all and 401.
Returns nil when no auth is configured.
Warns once per boot (from init/1) when no Origin policy was chosen at all.
Pass allowed_origins: "*" to opt out explicitly.