Wymcp. Response
(Wymcp v0.7.1)
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.
Beside them, send_accepted/1 sends the acceptance — HTTP 202 with no
body and so no envelope — to every notification and every client-delivered
JSON-RPC response wymcp takes, on both lanes: Wymcp.Plugs.Dispatch
answers a notification with it on each
lane, and on the legacy lane so do Wymcp.Methods.Initialized on a
completed handshake, Wymcp.Methods.Cancelled, and
Wymcp.Methods.DeliverResponse on the server-request round trip. One
taken notification still answers otherwise — notifications/initialized
under a server refusal, named in
send_accepted/1's @doc. The
sender is era-neutral; its legacy-lane callers go 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.
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 two whose bullets say why they do not — the malformed-JSON arm
has no parsed body to read a rule from, and the unknown modern method
echoes an id only a request can reach it with — 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— the distilled one-liner as the envelope'smessage, the locations underdata.errors.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 on the modern lane, each with its own error type::invalid_paramsnaming the missing or wrong-typed protocol field in itsmessageand carrying nodata, 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. 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 modern method — Wymcp.Methods.Unknown builds the:method_not_foundbody both eras share — the table's own message, nodata— 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.
One POST answer carries no envelope and uses no sender: the bare 404
answering an unrecognized session on a message the rejection-id rule gives
no id for (Wymcp.Plugs.Session). The rule's reason reaches here too —
what JSON-RPC forbids is an id-bearing reply to a response message,
a second answer to a request still outstanding — and an id-less error body
would be no reply to anything; the MCP spec even names it: 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.
The answer is legacy-only: it answers an unrecognized session, which
exists only on the legacy lane, and it goes 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 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 read stalled past its timeout, and a connection broken mid-body, leave no complete body to refuse and usually no client to answer, so they escape the mount as the adapter's own transport errors, which the host answers.
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, or a client's response to a server-initiated request — that wymcp has taken: HTTP 202 with no body, the connection halted.
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, every refusal of
the parse step but a malformed body, 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
| :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, 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.
The legacy-only reasons — the condition exists only because the legacy era
does, so the atom goes at the legacy decommission — are:
: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 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 server refusal's -32603 envelope
reads this rule too. It is no rejection, but the reason the rule exists —
never echo a server-minted id inside an error envelope — holds for any
error envelope wymcp builds, so Wymcp.Methods.Initialized reads the id
here rather than off the body: null for the wire's id-less notification,
the id echoed for a request-shaped body.
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.
The acceptance: the answer to an inbound message that asks for none — a notification, or a client's response to a server-initiated request — 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 every caller on both lanes
answers it identically. The status is the whole signal, and the exact
status matters: the TypeScript SDK's client transport (1.30) opens its
standalone GET stream only when notifications/initialized is answered
202, so a 200 here, however empty its body, leaves such a client with no
stream for anything the session later pushes. 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 and an
id-bearing reply to a response message. 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 — a null-id envelope
from Wymcp.Plugs.Validate and from Wymcp.Plugs.Session's 400s, the
bare 404 from Session's unrecognized-session arm — and the one notification
a consumer can still refuse after it was taken — notifications/initialized
under a server refusal — answers
through send_json/2 with its 500 envelope.
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, every refusal of
the parse step but a malformed body, 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" — the JSON-RPC dialect through
Wymcp.JsonRpc.error_response/3, which renders every message term, and the
plain-JSON dialect through JSON.encode!/1, which renders an atom other
than nil, true and false as a string and those three as JSON
literals. An is_binary/1 guard here would turn that supported answer into
a 500. The event carries the term as the site gave it.