Wymcp.JsonRpc (Wymcp v0.9.1)

View Source

JSON-RPC 2.0 envelopes and MCP protocol schema validation — the wire-format floor every answer stands on.

success_response/2 and error_response/3 build the envelope every answer is sent as; Wymcp.Response does the sending. The error map is the one home for wymcp's JSON-RPC error codes: each error_type/0 atom pairs with its {code, message} tuple, so a call site names the condition and never the number. This module owns the envelope's shape; it owns no decision about which condition applies — that is each plug's and each method module's.

The error envelope's contract

One rule, four clauses, over every JSON-RPC error this module builds — the 400s and 404s that are rejections and the 200-status method errors alike:

  1. code comes from the error-code table; a site names the condition, never the number.
  2. message is the site's one-line cause. The table's string is what a site sends when it has nothing more to say — -32601 Method not found.
  3. data is absent, or a map whose keys are drawn from a closed vocabulary of structured detail: errors (a validation failure's locations), header, expected, received (the header-binding check), supported, requested (the protocol version). No key carries a prose sentence, and no key carries the request or any part of it beyond the single value a condition is about, quoted under the rule Wymcp.Bound states in "What a refusal quotes" — as is anything a message quotes from the request.
  4. A new structured key joins in one place — the data_keys list this module keeps beside its error-code table, which Wymcp.ErrorEnvelopeInvariantTest reads to hold every envelope to this vocabulary — the same discipline the rejection table keeps, a gate rather than a catalogue. Clause 3 names the keys for a reader; that list is what the gate is checked against.

The sentence belongs in message because that is the slot the MCP schemas define for it — "A short description of the error … a concise single sentence" — and the slot a client renders: a tools/call refused for a wrong-typed argument showed the calling model Invalid params and nothing else, measured 2026-09-10, while the cause sat under data unread.

Message and output-schema validation

The self-contained MCP protocol schema — priv/schema-2026-07-28.json, JSON Schema 2020-12 — is compiled to a JSV.Root at build time through a module attribute, so an inbound request is validated against the official protocol definition with no runtime schema parsing. Its entry point is JSONRPCMessage, which validate_mcp_request/1 validates against. @external_resource on the path makes an edit to the schema file recompile this module.

A validation failure reaches the wire as a distilled error — the envelope's message, a one-line summary, plus data.errors, one entry per instance location, locations sorted, each location's problems deduplicated — never JSV.normalize_error/1's full tree. A distilled error is a shape, not one function's return value: the producers below return the two halves as one %{message:, errors:} map, and the site that answers splits them across the envelope's two slots. It carries only problems that state why the data was rejected — never one that only points at a deeper problem, or restates one its parent already reports. Where JSV produces the error, the producer meets this by keeping only the problems JSV rates at its cause level. This module distills it on two paths: validate_mcp_request/1 on the -32600 message path, whose reader is the human debugging a client, and validate_schema/2 on a tool's output-schema failure, which is logged and never reaches the wire. That path validates against a JSV.Root built ahead of the call, as the protocol schema's is: Wymcp.Router.init/1 builds it from the tool's output schema at the registration moment, never per tools/call. Wymcp.Methods.ToolsCall builds a conforming distilled error by hand for the -32602 it answers a malformed requestState or inputResponses with. The value gate (Wymcp.Tool.ValueGate) reads JSV's cause-level problems through this module as well, but answers in the tool dialect rather than as a distilled error. The full tree is JSON-encodable and was what previously reached the wire; it is simply too verbose for either reader — 2353 bytes against 435 for one garbage message, measured 2026-08-15.

Summary

Types

The atom naming one row of the error-code table: the JSON-RPC errors wymcp answers, each pairing its atom with a {code, message} tuple. Both error_response arities accept exactly these atoms. Distinct from telemetry's error_kind, which classifies a tool error's origin, not a JSON-RPC error.

Functions

The error envelope carrying the error-code table's own message and no data — the form for a site whose condition has one wording and nothing to locate.

The error envelope — the JSON-RPC error object wymcp answers with: code from the error-code table row error_type names, message the site's one-line cause, the caller's request_id as id, and data absent or a map of structured detail from the closed vocabulary this module's contract states — never a prose key, never the request.

Validates data against a built JSV.Root — for a tool's structured content, the root Wymcp.Router.init/1 stored for its output schema — returning :ok, or {:error, distilled} in the distilled shape above.

Types

error_type()

@type error_type() ::
  :parse_error
  | :invalid_request
  | :method_not_found
  | :invalid_params
  | :internal_error
  | :unsupported_protocol_version
  | :header_mismatch

The atom naming one row of the error-code table: the JSON-RPC errors wymcp answers, each pairing its atom with a {code, message} tuple. Both error_response arities accept exactly these atoms. Distinct from telemetry's error_kind, which classifies a tool error's origin, not a JSON-RPC error.

Functions

error_response(error_type, request_id)

The error envelope carrying the error-code table's own message and no data — the form for a site whose condition has one wording and nothing to locate.

error_response(error_type, request_id, opts)

The error envelope — the JSON-RPC error object wymcp answers with: code from the error-code table row error_type names, message the site's one-line cause, the caller's request_id as id, and data absent or a map of structured detail from the closed vocabulary this module's contract states — never a prose key, never the request.

opts carries :message and :data, both optional; without :message the table's own string is sent. Any other key raises: a misspelled :message would otherwise ship the table's generic string in place of the site's cause, silently, which is the answer this contract exists to end.

A :message term that is not a binary is rendered rather than refused. Wymcp.Plugs.Auth hands on a consumer's Wymcp.Auth.authenticate/1 reason as given, term() by contract, while the MCP schema types this field as a string — so the rendering happens here, once, rather than at each site that can receive one.

success_response(request_id, result)

validate_mcp_request(data)

validate_schema(root, data)

Validates data against a built JSV.Root — for a tool's structured content, the root Wymcp.Router.init/1 stored for its output schema — returning :ok, or {:error, distilled} in the distilled shape above.