ConduitMcp.Transport.Shared (ConduitMCP v0.11.0)

Copy Markdown View Source

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.Router pipeline up to the transport-specific plugs,
  • init/1 and call/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.

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

resolved_plug()

@type resolved_plug() :: {module(), term()} | nil

A plug resolved at init/1 time: the module and its initialised options.

Functions

__using__(opts)

(macro)

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_limit and :dispatch (StreamableHTTP uses this for :validate_session).

dispatch_post(conn, prepare \\ fn conn, _response -> {:ok, conn} end)

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

endpoint_config(server_module)

@spec endpoint_config(module() | nil) :: keyword()

Reads a use ConduitMcp.Endpoint server's compile-time config, which both transports use as defaults for options not passed explicitly.

init(opts, transport)

@spec init(keyword(), module()) :: keyword()

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.

put_protocol_version_header(conn, response_map)

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

resolve_auth_plug(auth_config)

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

shared_routes()

(macro)

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.

Call this last in the router — match _ must be the final route.

warn_if_origins_unset(opts, transport)

@spec warn_if_origins_unset(keyword(), module()) :: :ok

Warns once per boot (from init/1) when no Origin policy was chosen at all.

Pass allowed_origins: "*" to opt out explicitly.