Wymcp. Response
(Wymcp v0.8.7)
View Source
Sends wire responses over the Plug connection — the lowest-level output module in the pipeline — and records every rejection wymcp sends.
One primitive for the JSON-RPC
error dialect: send_json/2 sends a
JSON-RPC envelope as-is — every enveloped answer flows through it, and it
preserves any previously-set HTTP status — and halts the connection after
sending.
Beside it, send_accepted/1 sends the acceptance — HTTP 202 with no
body and so no envelope — to every notification wymcp takes:
Wymcp.Plugs.Dispatch answers each one with it, whatever its body.
Above them sits send_rejection/5, the shared rejection sender: it
assembles the -32600 envelope every wire check answers with, so a check
states a status and a message once and never builds an envelope of its
own. 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.
Every rejection ahead of Wymcp.Plugs.Classify carries a nil id, because
no message kind is known yet. Wymcp.Plugs.Pipeline's malformed-JSON arm
hardcodes it — no body parsed. Wymcp.Plugs.OriginCheck on POST, which that
chain runs ahead of the parse step, and the parse step's other arms get it
from the rejection-id rule, which has no kind to read — so the origin
check's 403 carries a null id even for a well-formed request that does have
one on the wire.
The call sites listed below assemble their rejection bodies themselves,
each answer carrying a different meaning rather than merely a different
shape — absorbing them would need the sender to take an error type and
structured data from its caller, leaving it with no opinion at all. Every
one of them still calls record_rejection/5 first, so bypassing the sender
bypasses neither the mark nor the event; each reads rejection_id/1 itself
except the one whose bullet says why it does not — the malformed-JSON arm
has no parsed body to read a rule from — so it does not bypass that rule
either:
Wymcp.Plugs.Validate's schema arm — the distilled one-liner as the envelope'smessage, the locations underdata.errors. Its other arm, the client-sent response, carries nodataand speaks the shared sender.Wymcp.Plugs.Pipeline's malformed-JSON arm of the parse step — error type:parse_error, nodata. The one site that hardcodesnil: it runs when no body parsed, so there is nothing to read a rule from. The step's other arms use the shared sender.Wymcp.Plugs.ProtocolFields— two answers, each with its own error type::invalid_paramsnaming the missing or wrong-typed protocol field in itsmessageand carrying nodata, and:unsupported_protocol_versionwithdata.supportedand, where the request offered one,data.requestedfor a version wymcp does not serve — the answer aninitializefrom an earlier revision meets.Wymcp.Plugs.HeaderBinding— three answers 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. Itsmessagenames the header and the condition, so it varies by row and by reason, asWymcp.JsonRpc's contract asks of every envelope.Wymcp.Plugs.Dispatch's unknown method —Wymcp.Methods.Unknownbuilds the:method_not_foundbody — the table's own message, nodata— with its id read throughrejection_id/1, and the plug sets the 404 that makes the answer a rejection. Only a request reaches that arm: a notification is answered with the acceptance ahead of it, andWymcp.Plugs.Validaterefuses a body it cannot classify — so the read is the rule's one door rather than a live branch, and stays right if a kind is ever added ahead of it.
A null-id envelope is what JSON-RPC and the MCP spec allow on input the
server cannot accept — the body "MAY comprise a JSON-RPC error response
that has no id" — which is why the 400s ahead of classification carry
one.
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 invariant holds for what reaches the mount unparsed; a parser ahead of
the mount owns what it raises. A host whose own
parser reads the body before forwarding answers what that parser refuses,
in its own way, and no rejection is recorded — which is why README's
Create your mount module step tells a host to leave the body unread. Two
failures of the parse step's own body read are no rejection either: a
stalled read, and a connection broken
mid-body, leave no complete body to refuse and usually no client to
answer, so they escape the mount as an exception the host answers — the
adapter's own transport error, or the Plug.TimeoutError Plug raises
for the stalled read the parse step's reader reports;
Wymcp.Plugs.Pipeline states the rule.
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.
The acceptance: the answer to an inbound message that asks for none — a notification — that wymcp has taken: HTTP 202 with no body, the connection halted.
Sends a rejection as a -32600 envelope
and halts — the shared sender behind the wire checks' rejections and every
refusal of the parse step but a malformed body.
Types
@type rejection_reason() ::
:duplicated_header
| :origin_not_allowed
| :unauthenticated
| :auth_error
| :invalid_message
| :not_a_request
| :invalid_protocol_fields
| :unsupported_protocol_version
| :missing_header
| :header_mismatch
| :invalid_header
| :unsupported_media_type
| :query_too_long
| :malformed_query
| :body_too_large
| :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, message
validation's schema and client-sent-response 400s, the protocol fields'
two 400s, the header-binding check's three. One condition reached
by two rejecters shares one atom: :duplicated_header is answered by the
origin check, the parse step and the singleton-header check alike, each
naming its own header on the wire.
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, so a handler never parses
a body to learn them.
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 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.
The -32603 envelope of method containment
reads this rule too. It is no rejection, but the reason the rule exists —
never echo an id that was no request's inside an error envelope — holds
for any error envelope wymcp builds.
An id on a non-request message is one the client attached to a body no request could correlate to — a JSON-RPC response a client sent, which the spec forbids, or a truncated body. Echoing it inside an error envelope would offer a strict client an answer to a request it never made.
The rule is stated positively rather than as "except on a response" because
Wymcp.Plugs.Classify tags every such body :unknown — a response and a
truncated request are precisely the bodies where a request and a response
cannot be told apart — so none of them keeps its id. 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 a connection that
parsed no body — a hand-built one, or one refused ahead of the parse step
— 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.
The acceptance: the answer to an inbound message that asks for none — a notification — that wymcp has taken: HTTP 202 with no body, the connection halted.
One sender rather than a bare send_resp(202, "") at each site, so the
shape Streamable HTTP prescribes for an accepted notification — "202
Accepted with no body" — is written once and answered identically
wherever a notification is taken. The status is the whole signal: no
content type is set because there is nothing to type, and no id rides
because there is nothing to correlate — JSON-RPC forbids any reply to a
notification. A previously-set status is not consulted — unlike
send_json/2, which preserves one — because no caller has a reason to
set one ahead of an acceptance.
A message wymcp cannot accept never reaches this function: a wire check
ahead of dispatch refuses it with an error status and a null-id envelope,
Wymcp.Plugs.Validate's among them.
Sends a rejection as a -32600 envelope
and halts — the shared sender behind the wire checks' rejections and every
refusal of the parse step but a malformed body.
It records the rejection through record_rejection/5 before it 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 — and a pair the table does not name is
refused by the table guard as a FunctionClauseError, never silently
mis-bound.
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.
message is not guarded: a consumer's Wymcp.Auth.authenticate/1 may
reject with an atom reason ({:error, :invalid_token}), which
Wymcp.JsonRpc.error_response/3 renders as the string "invalid_token"
the way it renders every message term. An is_binary/1 guard here would
turn that supported answer into a 500. The event carries the term as the
site gave it.