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's session_terminated/2 — error type :session_not_found and no data map, 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_request alongside the error.
  • Wymcp.Plugs.Pipeline's body-parse rescue — error type :parse_error, data key :reason. The one site that hardcodes nil: 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_params with data.reason for a missing or wrong-typed protocol field, and :unsupported_protocol_version with data.supported + data.requested for 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.header names the mirrored header, data.expected the body's value, and data.received the header's, omitted where there is none to report. It is also the one site of the six whose message is not the error-code table's fixed string: the sentence names the header and the condition, so it varies by row and by reason, and Wymcp.JsonRpc.error_response/4 is what carries it.
  • Wymcp.Plugs.Dispatch's unknown modern method — Wymcp.Methods.Unknown builds the :method_not_found body 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.Dispatch delivers a response message before it branches on era, and Wymcp.Plugs.Validate refuses a body it cannot classify — so the raw id and rejection_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

rejection_reason()

@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

record_rejection(conn, rejecter, reason, status, message)

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.

rejection(conn)

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.

rejection_id(conn)

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.

rejection_table()

Every pair the mark-and-emit helper accepts — one row per rejecting arm; a rejection outside this table cannot be sent.

send_json(conn, response)

send_plain_error(conn, status, message)

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.

send_rejection(conn, rejecter, reason, status, message, dialect \\ :json_rpc)

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.