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's message, the locations under data.errors. Its other arm, the client-sent response, carries no data and speaks the shared sender.
  • Wymcp.Plugs.Pipeline's malformed-JSON arm of the parse step — error type :parse_error, no data. The one site that hardcodes nil: 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_params naming the missing or wrong-typed protocol field in its message and carrying no data, and :unsupported_protocol_version with data.supported and, where the request offered one, data.requested for a version wymcp does not serve — the answer an initialize from 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.header names the mirrored header, data.expected the body's value, and data.received the header's, omitted where there is none to report. Its message names the header and the condition, so it varies by row and by reason, as Wymcp.JsonRpc's contract asks of every envelope.
  • Wymcp.Plugs.Dispatch's unknown method — Wymcp.Methods.Unknown builds the :method_not_found body — the table's own message, no data — with its id read through rejection_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, and Wymcp.Plugs.Validate refuses 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

rejection_reason()

@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

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, so a handler never parses a body to learn them.

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 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.

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.

rejection_table()

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

send_accepted(conn)

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.

send_json(conn, response)

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

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.