Wymcp (Wymcp v0.8.3)

View Source

MCP server library for Elixir — a Plug-based implementation of the MCP JSON-RPC 2.0 protocol, speaking the 2026-07-28 revision on one endpoint. Tools are the only MCP primitive wymcp implements; resources and prompts are not served.

This module is the map: every published module, what it owns, and why it exists. README narrates why the project exists and how to mount a first server; the glossary holds the vocabulary and says where each term is defined. Neither is repeated here.

flowchart LR
    CA(Consumer App) -->|implements| Tool
    CA -->|implements| Auth

    Router --> Pipeline["Plugs.Pipeline"]
    Pipeline --> OriginCheck["Plugs.OriginCheck"]
    Pipeline --> AuthPlug["Plugs.Auth"]
    Pipeline --> SingletonHeaders["Plugs.SingletonHeaders"]
    Pipeline --> Classify["Plugs.Classify"]
    Pipeline --> ProtocolFields["Plugs.ProtocolFields"]
    Pipeline --> HeaderBinding["Plugs.HeaderBinding"]
    HeaderBinding --> Schema
    AuthPlug --> Auth
    Pipeline --> Validate["Plugs.Validate"]
    Pipeline --> Dispatch["Plugs.Dispatch"]
    Dispatch --> Methods["Methods.*"]
    Dispatch --> Discover["Methods.Discover"]
    Discover --> Modern["Wymcp.Modern"]
    Methods --> Modern
    Modern --> ServerInfo["Wymcp.ServerInfo"]
    Methods --> Tool
    Methods --> Help
    Methods --> Telemetry
    Help --> Schema
    Help --> Tool
    Help --> Actions
    Tool --> Schema["Tool.Schema"]
    Tool --> Actions["Tool.Actions"]
    Schema --> Actions
    Actions --> Tool
    Tool --> Context
    Tool --> Hint
    Router -->|compile| ServerInfo

    Validate --> JsonRpc

The consumer surface

Wymcp.Router is the Plug entry point. It owns the routes — POST, and a fallthrough that answers every other verb 405, a POST at a path it does not serve 404, and marks its answer either way — the options a consuming app declares in its mount module together with the router-option invariant that closes and validates that set, and the wire-check invariant: the ordered wire-check list every served route runs and the served-verb list itself, each stated once in that module and held to the routes and to the POST chain by Wymcp.WireCheckInvariantTest. It also owns the mount's build artifacts: Wymcp.ServerInfo's partial, and every mount tool's tools/list definition, assembled at the registration moment from the callbacks the validation chain has just read. It owns no method's answer — Wymcp.Methods.ToolsList decides which tools a listing carries and shapes the response around the definitions it looks up — and not the POST chain's interior. It exists so a consuming app mounts MCP with a use and a forward, and never touches the pipeline.

Wymcp.Tool is the behaviour a consuming app implements to expose capabilities. It owns the action-dispatched pattern — one tool name multiplexing many actions — the action-schema vocabulary and the format catalogue a consumer writes against, the callback-surface check, the dispatch gates that validate a call before the handler runs, and the contract governing the text a consumer writes. It owns neither the validation of the schemas it describes — that is Wymcp.Tool.Actions — nor a wire envelope. It exists because a tool author should describe actions, not JSON-RPC.

Wymcp.Context is the struct every Wymcp.Tool.run_action/3 receives. It owns the per-call view of the world — request identity and metadata, the per-request assigns, and the answers the client has already given this call — and the pure builders that turn a tool's return value into MCP content. It owns no state that outlives the call. It exists so a tool never reaches for a Plug.Conn.

Wymcp.Hint is the struct for follow-up action suggestions. It owns the hint's shape and its construction-time validation in Wymcp.Hint.new/1 — every field checked against the type it publishes for the wire. It owns no decision about when a hint is emitted; that is Wymcp.Tool's Wymcp.Tool.hints/2.

Wymcp.Auth is the consumer contract for request authentication. It owns the Wymcp.Auth.authenticate/1 callback and the rule that authentication reads connection data, never a request body. It owns neither the 401 nor its challenge — the auth check does. It exists so credentials stay the host's business.

Wymcp.Auth.Noop is the :auth default: it accepts every request. It exists because a consumer meets it without choosing it — mount without :auth and this is what runs — so it is named here rather than left to be discovered.

There is deliberately no per-request or lifecycle hook for a consumer to implement: observability is telemetry's — Wymcp.Telemetry's events, Wymcp.Telemetry.Logger's lines, and the rejection mark Wymcp.Response.rejection/1 reads — while the cross-cutting concerns that are not observability, rate limiting and per-route auth among them, compose as host plugs ahead of the mount.

Wymcp.Help is the framework-owned introspection tool, injected into every server under the reserved name help. It owns the three answer levels and the rule that unknown targets error naming the valid ones. It owns no content of its own — it renders what tools declare, from the same source the tools/list description builder uses, so the two cannot drift. What it will not do is render around a gap; the guarantee is not its own, but the one every reader inherits from obtaining a schema (the read-side corollary, Wymcp.Tool.Actions).

Wymcp.ProtocolVersion is the single source of truth for version support: the one revision wymcp serves and the names of the protocol fields. It owns no answer to any other revision — Wymcp.Plugs.ProtocolFields gives that.

Wymcp.Telemetry is the catalogue of :telemetry events wymcp emits, with their measurements and metadata. It owns the event contract, not the emission: each event is emitted where it happens. It exists so a consuming app can attach handlers against a documented surface — the library emits, a handler renders, the consumer chooses.

Wymcp.Telemetry.Logger is the handler wymcp ships against that surface: it renders the catalogue's events as structured Logger lines under one policy — its line table says which — and it is attached at boot unless the consuming app turns it off with one key. It owns the lines and nothing else — no event, no metadata — so a consumer wanting other levels or keys attaches a handler of its own rather than living with wymcp's.

Wymcp.Testing provides helpers for a consuming app's own test suite — building a context, running a tool the way the wire runs it, building a request body the router serves and the mirrored headers it implies, and response unwrapping. It owns test conveniences only, and ships in the package because a consumer's tests need it.

Internals

These modules are wymcp's own machinery. A consuming app does not call them; they are catalogued because the map is complete or it is not a map.

Wymcp.JsonRpc owns the JSON-RPC envelope shapes, the error-code table, the contract every error envelope keeps, the build-time-compiled protocol schema root, and the distilled shape every validation failure reaches the wire as.

Wymcp.Response owns sending, and the record of every rejection: the envelope sender and the acceptance sender beside it — the 202 with no body every accepted notification answers — the shared rejection sender above them, the mark-and-emit helper beneath both — which writes the rejection mark, emits the rejection event, and accepts only a rejecter and reason its declared table names — the two readers of that mark, and the rule that an error envelope echoes an id only when the inbound message was a request. The rejection invariant is its moduledoc's to state. Every sender halts the connection, so no downstream plug runs after an answer is sent.

Wymcp.Modern owns the 2026-07-28 result shape — the completed result, and the input-required one a call answers when it still needs input from the user — with the result-type stamp, the serverInfo _meta, the cache-hint defaults, and the requestState codec that carries answers between rounds. Framework defaults with zero consumer surface.

Wymcp.ServerInfo owns the server identity map every result carries — the serverInfo partial precomputed from the :server_info option at the mount module's compile (an unknown key refuses the compile), plus the per-request merge of name and version from application config.

Wymcp.Tool.Actions owns everything the framework asks of the actions a tool module declares: the validator chain that runs at each action-schema validation moment, the action-schema invariant those moments enforce, and the obtaining accessors through which every reader — dispatch, Wymcp.Help, Wymcp.Tool.Schema — gets a schema whose mandatory keys are already checked. It owns neither the vocabulary nor the format catalogue: both stay with the behaviour in Wymcp.Tool, and the chain reads the key list back through that module's accessor rather than restating it.

Wymcp.Tool.Schema owns the inputSchema a tool publishes: an action enum whose description carries the action summaries, a bare data object, and the closed key set the root declares as additionalProperties: false. It obtains that tool's action schemas itself rather than being handed them; that guarantee is not its own, but the one every reader inherits from obtaining a schema (the read-side corollary, Wymcp.Tool.Actions). It deliberately owns no per-action constraint, and no enforcement of a call — both are dispatch's, and the full schemas are surfaced on demand by Wymcp.Help — which is what keeps the tools/list payload compact. It does own one enforcement of a schema: validate_header_annotations!/2 holds a hand-written input_schema/0's header annotations to the spec's constraints at both registration moments, beside the annotation build/1 writes — reader, writer and validator in one module. Obtaining points this module at Wymcp.Tool.Actions, which points at nothing here in return — the diagram above carries that single edge. It closes a three-module cycle that exists only at run time, Wymcp.Tool → Wymcp.Tool.Schema → Wymcp.Tool.Actions → Wymcp.Tool, whose last leg reads the key list rather than building anything.

Wymcp.Bound owns wymcp's one bound — the number, read in each value's own unit — and the total renderer that applies it, in two forms: Wymcp.Bound.render/1, always a string, for the wire and for a string interpolation, and Wymcp.Bound.value/1, which keeps a scalar as itself, for a log line's metadata. It owns no decision about which values are bounded — that is each caller's — and its moduledoc is where bounded and totality are defined.

The plug chain

The plugs are one story, not several: a POST's order of checks. In that order — Wymcp.Plugs.OriginCheck, Wymcp.Plugs.Classify, Wymcp.Plugs.Auth, Wymcp.Plugs.SingletonHeaders, Wymcp.Plugs.ProtocolFields, Wymcp.Plugs.HeaderBinding, Wymcp.Plugs.Validate, Wymcp.Plugs.Dispatch — assembled by Wymcp.Plugs.Pipeline, which owns the order itself, exposed as its chain and held to the router's list by Wymcp.WireCheckInvariantTest; the declaration of which plugs ahead of Wymcp.Plugs.Validate are body-bound, and why; and the inline parse step that sits between the origin check and the rest. Which of them are wire checks, and the order those run in, is Wymcp.Router's wire-check invariant to state. Each plug's own moduledoc carries its rules; the order, and why it is that order, lives in Wymcp.Plugs.Pipeline.