Wymcp.JsonRpc (Wymcp v0.7.1)

View Source

JSON-RPC 2.0 envelopes and MCP protocol schema validation — the wire-format floor both eras share.

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, the 200-status method errors, and the 500 a server refusal answers 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, -32001 Session terminated.
  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.
  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

One self-contained MCP protocol schema per era — priv/schema-2025-11-25.json and 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. Both schemas name the same entry point, JSONRPCMessage; validate_mcp_request/2 selects the era's root from the era Wymcp.Plugs.Validate passes it. @external_resource on both paths makes an edit to either 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. It 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. This module distills it on two paths: validate_mcp_request/2 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. Wymcp.Methods.ToolsCall builds a conforming one by hand for the -32602 arguments path, whose reader is the calling LLM. 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.

Types

error_type()

@type error_type() ::
  :parse_error
  | :invalid_request
  | :method_not_found
  | :invalid_params
  | :internal_error
  | :session_not_found
  | :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 and Wymcp.Methods.Initialized a consumer's Wymcp.Server.init/2 reason, both term() by contract, while the MCP schemas type 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(atom, data)

validate_schema(schema, data)