Wymcp.Plugs.Pipeline (Wymcp v0.7.1)

View Source

The POST plug chain: every check and stage a POST passes through, in the one order that makes each stage's precondition true.

Wymcp.Plugs.OriginCheck        # before anything is parsed
parse_body                     # the parse step
Wymcp.Plugs.Classify           # message kind
Wymcp.Plugs.Auth               # authentication
Wymcp.Plugs.SingletonHeaders   # singleton-header cardinality
Wymcp.Plugs.Era                # era classification
Wymcp.Plugs.ProtocolFields     # modern-lane enforcement
Wymcp.Plugs.HeaderBinding      # modern-lane header binding
Wymcp.Plugs.Session            # legacy-lane session resolution
Wymcp.Plugs.Validate           # MCP schema
Wymcp.Plugs.Dispatch           # method → its answering module

Why that order. The origin check runs before anything is parsed because nothing has validated Origin when it runs — Wymcp.Router's wire-check invariant, which is also where the wire checks and their order are stated. The parse step and classification sit between the origin check and the remaining wire checks so those checks' rejections can carry the body's id and know the message kind. Wymcp.RouterTest pins that placement end to end — its authentication describe proves the tag is present when the auth check, the first consumer, reads it, and its singleton-header check describe proves the same at the next site; Wymcp.Plugs.AuthTest and Wymcp.Plugs.SingletonHeadersTest call their plug directly rather than routing a request, so neither sees the order. Era classification runs after the singleton-header check, which guarantees at most one Mcp-Session-Id for it to read. The header-binding check runs after Wymcp.Plugs.ProtocolFields, which has just proven the body's protocol version is a string naming a served revision — so its protocol-version row has a known-good body value to compare against, and a malformed _meta meets its own -32602 first. Its other rows read body fields nothing has validated yet, and answer none of them: a body value with no header spelling binds nothing, so the plug or method that owns the defect still answers it. That plug's own moduledoc carries the rule. Validation runs last before dispatch, so the wire and session checks answer first.

The chain is gated, not derived. The wire checks cannot be spliced in as one list — the parse step and classification interleave them — so this module writes the order by hand and exposes it through chain/0, and Wymcp.WireCheckInvariantTest holds it to the router's list, in two cells that both read the chain only as far as Wymcp.Plugs.Session — the point past which a request has touched session state: every wire check is in that prefix, in list order, and every other plug in it is declared body-bound in body_bound_plugs/0, with the reason it runs on POST only. A plug added ahead of Wymcp.Plugs.Session therefore joins the wire-check list, and so runs on GET and DELETE, or declares why it cannot; a wire check moved past Wymcp.Plugs.Session leaves that prefix, and fails the first of the two cells. chain/0 reads Plug.Builder's accumulated @plugs, which is stored newest-first as {plug, opts, guards} tuples, so the accessor reverses it and keeps the first element; a function plug appears as its bare atom, exactly as it is declared.

GET and DELETE do not run this pipeline. Wymcp.Router runs the wire checks in those route bodies, straight from its list, speaking the plain-JSON dialect.

The parse step

The parse step is an inline function rather than a plug so that a request it refuses is answered in wymcp's own dialect and recorded as a rejection, instead of escaping the mount as a Plug exception. It reads the Content-Type header's cardinality itself — Plug.Parsers raises nothing when the header is absent and reads only the first of several — and then runs Plug.Parsers, whose own order decides the rest. A request wrong in more than one way meets the first arm in this order:

ConditionStatusReason
no Content-Type header415:unsupported_media_type
more than one Content-Type header400:duplicated_header
a query string over the query-string ceiling414:query_too_long
a query string carrying invalid UTF-8, or keys nested past Plug's maximum400:malformed_query
a type other than application/json (with any parameters) or an application/*+json subtype415:unsupported_media_type
a body over the body ceiling413:body_too_large
a body that does not decode as JSON400:malformed_json

An absent Content-Type is refused under the same reason and message as a type the parser does not take: either way nothing declares the body JSON. A malformed body answers -32700, its envelope assembled here; every other arm answers -32600 through Wymcp.Response.send_rejection/6. Every envelope's id is null: nothing has classified the message yet. The two arms that answer after the body read has begun — the 413 and the malformed-JSON 400 — close an HTTP/1.x connection with their answer: they answer on the connection as it stood before the read, so a keep-alive connection would otherwise be drained from the next request's bytes. Every other arm answers before any body is read and leaves the connection open; HTTP/2 frames each request as its own stream and gets no such header. The two ceilings — 8000000 bytes of body and 1000000 bytes of query string, each inclusive — and the query string's UTF-8 validation are set here rather than left to Plug.Parsers' defaults, so a refusal can name its ceiling and a Plug upgrade cannot change what the wire says. Over HTTP/1.1, Bandit's default request-line limit sits far below the query-string ceiling, so there a query string anywhere near it is refused by the adapter's own 414 before the mount runs.

Summary

Functions

Callback implementation for Plug.call/2.

Callback implementation for Plug.init/1.

Functions

call(conn, opts)

Callback implementation for Plug.call/2.

init(opts)

Callback implementation for Plug.init/1.