Wymcp. Response
(Wymcp v0.6.2)
View Source
Sends wire responses over the Plug connection — the lowest-level output module in the pipeline — and records every rejection wymcp sends.
Two primitives, one per structured
error dialect: send_json/2 sends a
JSON-RPC envelope as-is — every JSON-RPC-enveloped POST answer flows
through it, and it preserves any previously-set HTTP status — and
send_plain_error/3 sends the plain-JSON dialect's flat
%{error: message} object, the rejection body of the GET/DELETE routes.
Both halt the connection after sending.
The plain-JSON dialect is legacy-only: it exists for the GET and DELETE
rejection bodies, and it goes with those routes at the legacy
decommission.
Above them sits send_rejection/6, the shared rejection sender: it takes
the dialect as a parameter and assembles the -32600 envelope or the flat
object accordingly. That is what lets a wire check — each of the plugs
Wymcp.Router's wire-check invariant names runs on both POST and the
GET/DELETE routes — state a status and a message once rather than carry a
private dialect switch. rejection_id/1 holds the rule for
what id such an envelope echoes, and the sender applies it — no call site
passes an id, so none can choose otherwise.
Beneath both sits record_rejection/5, which every rejection passes
through whether or not it uses the shared sender: it writes the
rejection mark and emits
[:wymcp, :wire, :reject]. It names the
rejecter and a
rejection reason, and accepts only
a pair the rejection table carries.
rejection/1 reads the mark back — the consumer's second door onto which
rejecter refused a request and why, beside the event.
Two places answer nil structurally rather than by consulting the
rejection-id rule, and both do so because no message kind is known to them
yet: Wymcp.Plugs.Pipeline's body-parse rescue (no body parsed) and
Wymcp.Plugs.OriginCheck on POST, which that chain runs ahead of
Wymcp.Plugs.Classify — so its 403 carries a null id even for a well-formed
request that does have one on the wire.
Six call sites assemble their rejection bodies themselves, each answer
carrying a different meaning rather than merely a different shape —
absorbing them would need both an error-type and a raw data-map parameter,
leaving the sender with no opinion at all. All six still call
record_rejection/5 first, so bypassing the sender bypasses neither the
mark nor the event; four of the six read rejection_id/1 themselves, so
it does not bypass that rule either:
Wymcp.Plugs.Session'ssession_terminated/2— error type:session_not_foundand nodatamap, matching the TypeScript SDK byte-for-byte; it branches on the rule rather than passing it a value, sending an envelope exactly when the rule yields an id. This site is legacy-only: sessions exist only on the legacy lane, and it goes at the legacy decommission.Wymcp.Plugs.Validate— echoes:original_requestalongside the error.Wymcp.Plugs.Pipeline's body-parse rescue — error type:parse_error, data key:reason. The one site that hardcodesnil: it runs when no body parsed, so there is nothing to read a rule from.Wymcp.Plugs.ProtocolFields— two answers on the modern lane, each with its own error type and data shape::invalid_paramswithdata.reasonfor a missing or wrong-typed protocol field, and:unsupported_protocol_versionwithdata.supported+data.requestedfor a version no era serves.Wymcp.Plugs.HeaderBinding— three answers on the modern lane sharing one error type,:header_mismatch(-32020), which the shared sender cannot speak at all:data.headernames the mirrored header,data.expectedthe body's value, anddata.receivedthe header's, omitted where there is none to report. It is also the one site of the six whosemessageis not the error-code table's fixed string: the sentence names the header and the condition, so it varies by row and by reason, andWymcp.JsonRpc.error_response/4is what carries it.Wymcp.Plugs.Dispatch's unknown modern method — Wymcp.Methods.Unknown builds the:method_not_foundbody both eras share, echoing the request's own id, and the plug alone sets the 404 that makes the modern lane's answer a rejection where the legacy lane's 200 is not. Echoing the body's id directly is safe there because only a request reaches that arm:Wymcp.Plugs.Dispatchdelivers a response message before it branches on era, andWymcp.Plugs.Validaterefuses a body it cannot classify — so the raw id andrejection_id/1's answer coincide.
Two POST answers carry no envelope and so use no sender: the bare 202
acknowledging a client-delivered JSON-RPC response
(Wymcp.Methods.DeliverResponse) and the bare 404 answering an
unrecognized session on a message the rejection-id rule gives no id for
(Wymcp.Plugs.Session). What JSON-RPC forbids is an id-bearing reply to
a response message — that is a second answer to a request still outstanding.
An id-less error body is not a reply to anything, and the MCP spec names it
explicitly: on input the server cannot accept, the body "MAY comprise a
JSON-RPC error response that has no id". So the 400s legitimately carry a
null-id envelope; the 404 stays empty for a different reason — a client
receiving it must start a new session, so the status is the whole signal and
a diagnostic string would name no next action.
Both answers are legacy-only: the 202 acknowledges a client-delivered
response on the server-request round trip and the 404 an unrecognized
session, and neither exists off the legacy lane. The round trip's
contract survives via MRTR on the modern
lane, and both answers go at the legacy decommission.
The rejection invariant
Every rejection sets the rejection mark and emits
[:wymcp, :wire, :reject] exactly once, and rejection_table/0,
rejection_reason/0 and Wymcp.RejectionInvariantTest's scenarios name
the same pairs.
The table is what closes it: record_rejection/5 refuses a pair no row
names, so a new rejecting arm cannot answer the wire until it has a row,
and the sweep derives one scenario per row, so a row cannot exist without
a request that trips it. What neither reaches is a rejection sent past the
helper entirely — a bare send_resp(400, …) marking nothing and emitting
nothing — which review catches and no gate here does.
The mark's writer and readers live in this module because every check
depends on it at run time and nothing here depends on a check, so the pair
adds no compile edge; a check reading a key owned by Wymcp.Router would
close a compile cycle through Wymcp.Plugs.Pipeline, which initializes the
checks at its own compile. The table's rows keep that property by naming
their modules as fully-qualified atoms rather than aliases: an alias here
— in a module attribute or a function body alike — draws this module into
that cycle, which mix xref graph --label compile-connected reports and
nothing else would. The cost is that the compiler no longer checks those
names, which is why the sweep asserts each one resolves to a loaded module.
Renamed from Vancouver's Method module for clarity: this module's only job
is sending the HTTP response, it has nothing to do with JSON-RPC methods.
Summary
Types
The atom naming the condition a rejection answers — one per arm of each
rejecter, so a {rejecter, reason} pair identifies the site. It is the
closed set rejection_table/0 declares, and what
[:wymcp, :wire, :reject] carries under reason. It names the condition
the client can act on, never the JSON-RPC code: a body that will not parse
is :malformed_json, not :parse_error.
Functions
Records a rejection on the connection and
emits [:wymcp, :wire, :reject], returning the marked connection — the one
place both halves of the rejection invariant happen, whether or not the
caller goes on to use the shared sender.
The rejection mark this connection
carries, or nil when nothing rejected: a map of :rejecter, the module,
and :reason, one of rejection_reason/0.
The id a rejection envelope echoes: the body's id on a JSON-RPC
request, and nil on every other message kind.
Every pair the mark-and-emit helper accepts — one row per rejecting arm; a rejection outside this table cannot be sent.
Sends a plain-JSON dialect error — the flat %{error: message} object the
GET/DELETE routes and their wire checks answer with — and halts.
Sends a rejection in the given error
dialect and halts — the shared sender behind the wire checks' rejections,
the 400s of Wymcp.Plugs.Era and Wymcp.Plugs.Session, and the GET and
DELETE routes' own two.
Types
@type rejection_reason() ::
:duplicated_header
| :origin_not_allowed
| :unauthenticated
| :auth_error
| :era_mix_session_header
| :era_mix_initialize
| :missing_session_header
| :session_not_ready
| :protocol_version_mismatch
| :session_not_found
| :invalid_message
| :invalid_protocol_fields
| :unsupported_protocol_version
| :missing_header
| :header_mismatch
| :invalid_header
| :malformed_json
| :unknown_method
The atom naming the condition a rejection answers — one per arm of each
rejecter, so a {rejecter, reason} pair identifies the site. It is the
closed set rejection_table/0 declares, and what
[:wymcp, :wire, :reject] carries under reason. It names the condition
the client can act on, never the JSON-RPC code: a body that will not parse
is :malformed_json, not :parse_error.
Two arms of one check take two reasons, so the pair still identifies the
site — the origin check's cardinality 400 and allowlist 403, the protocol
fields' two modern 400s, the header-binding check's three. The same
condition reached on two routes shares one atom:
:missing_session_header and :session_not_found are each
answered by both Wymcp.Plugs.Session on POST and Wymcp.Router on the
GET and DELETE routes, in different words on the wire.
Six of the eighteen are legacy-only — the condition exists only because
the legacy era does, so the atom goes at the legacy decommission:
:era_mix_session_header, :era_mix_initialize,
:missing_session_header, :session_not_ready,
:protocol_version_mismatch and :session_not_found.
Functions
Records a rejection on the connection and
emits [:wymcp, :wire, :reject], returning the marked connection — the one
place both halves of the rejection invariant happen, whether or not the
caller goes on to use the shared sender.
The {rejecter, reason} pair is the guard: only a pair
rejection_table/0 names is accepted, and a pair outside it is a
FunctionClauseError rather than a silently mis-attributed line. Only
wymcp's own code reaches here — the POST chain is a fixed Plug.Builder
list and a consumer adds nothing inside it — so a new arm added without a
row fails in wymcp's own suite, at its scenario or at whichever test first
reaches the site, never in a consumer's production.
The event fires here, at mark time, before anything is sent: it records
wymcp's decision, not its delivery, so a client that disconnects mid-write
still produced a rejection event. status and message ride the event
rather than being read back off the connection, because the plain-JSON
dialect's flat object carries neither in a form worth parsing.
era is on the event only where the connection carries one:
Wymcp.Plugs.Era runs partway down the POST chain and not at all on the
GET and DELETE routes, so a rejection ahead of it has no lane to name and
the key is absent rather than guessed. Read it with Map.get/3.
The era key is legacy-only: it exists only because two eras do, and it
goes at the legacy decommission.
The rejection mark this connection
carries, or nil when nothing rejected: a map of :rejecter, the module,
and :reason, one of rejection_reason/0.
This is the consumer's second door onto a rejection, beside
[:wymcp, :wire, :reject]. A host application that already writes an
access line reads it where the connection is final — a
Plug.Conn.register_before_send/2 callback, or a
[:phoenix, :endpoint, :stop] handler — and gets which rejecter refused
the request and why, on the line it already emits.
One Plug.Conn.put_private/3 writes both fields, so no half-mark can
exist.
The id a rejection envelope echoes: the body's id on a JSON-RPC
request, and nil on every other message kind.
An id on a non-request message was minted by the server for a request of its own — an elicitation call. Echoing it inside an error envelope offers a strict client a second, conflicting answer to a request the client is still waiting on.
The rule is stated positively rather than as "except on a response message"
because Wymcp.Plugs.Classify tags :response only when an id sits beside
a result or an error. A truncated client answer tags :unknown, and
those are precisely the bodies where a request and a response cannot be told
apart — so they must not keep their id either. Classify's :request test is
looser than schema validity, so a recognisable malformed request keeps its
id and stays correlatable.
Distinct from the rejection event's message_id, which carries the body's
id whatever the message kind — an operator diagnosing a rejection wants the
id the client actually sent.
Map.get/3 rather than conn.body_params["id"]: on the routes that parse no
body, body_params is a Plug.Conn.Unfetched struct whose Access
callbacks raise.
Every pair the mark-and-emit helper accepts — one row per rejecting arm; a rejection outside this table cannot be sent.
Sends a plain-JSON dialect error — the flat %{error: message} object the
GET/DELETE routes and their wire checks answer with — and halts.
The given status is sent as-is; a previously-set conn.status is not
consulted (unlike send_json/2, which preserves it).
This primitive is legacy-only: the plain-JSON dialect exists for the GET and DELETE rejection bodies, and it goes with those routes at the legacy decommission.
Sends a rejection in the given error
dialect and halts — the shared sender behind the wire checks' rejections,
the 400s of Wymcp.Plugs.Era and Wymcp.Plugs.Session, and the GET and
DELETE routes' own two.
It records the rejection through record_rejection/5 before either dialect
sends, so the mark and the event are the sender's and no caller can send a
rejection without naming itself and its condition. Both are required rather
than defaulted: a new rejecting arm that leaves either out has no matching
arity — a plain compile failure — or, when it happens to match the
defaulted-dialect arity, is refused by the table guard as a
FunctionClauseError, never silently mis-bound.
Taking the dialect as a parameter is what lets a plug that runs on both POST and the GET/DELETE routes state its status and message once, instead of carrying a private two-clause dialect switch.
The envelope's id is not a parameter: it comes from rejection_id/1, so no
call site can pass one and none can choose otherwise. That is why the
function is named for the rejection rather than for the error —
Wymcp.JsonRpc.error_response/3 remains the general builder for errors that
are not rejections. The derivation sits on the :json_rpc path alone: the
plain-JSON dialect's flat object carries no id field at all, so computing one
on that path would feed a value nothing reads.
The dialect parameter is legacy-only: the plain-JSON dialect exists for
the GET and DELETE rejection bodies, so the parameter and its
:plain_json clause go with those routes at the legacy decommission.
message is guarded no more tightly than send_plain_error/3 guards its
own: a consumer's Wymcp.Auth.authenticate/1 may reject with an atom
reason ({:error, :invalid_token}), which both dialects render as the string
"invalid_token". An is_binary/1 guard here would turn that supported
answer into a 500. The event carries the term as the site gave it.