Wymcp (Wymcp v0.6.1)

View Source

MCP server library for Elixir — a Plug-based implementation of the MCP JSON-RPC 2.0 protocol, serving the modern and the legacy era 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
    CA -->|implements| Server

    Router --> Pipeline["Plugs.Pipeline"]
    Router --> OriginCheck["Plugs.OriginCheck"]
    Router --> AuthPlug["Plugs.Auth"]
    Router --> SingletonHeaders["Plugs.SingletonHeaders"]
    Pipeline --> OriginCheck
    Pipeline --> AuthPlug
    Pipeline --> SingletonHeaders
    Pipeline --> Classify["Plugs.Classify"]
    Pipeline --> Era["Plugs.Era"]
    Pipeline --> ProtocolFields["Plugs.ProtocolFields"]
    Pipeline --> HeaderBinding["Plugs.HeaderBinding"]
    HeaderBinding --> Schema
    Pipeline --> SessionPlug["Plugs.Session"]
    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 --> Session
    Methods --> Help
    Help --> Schema
    Help --> Tool
    Help --> Actions
    Tool --> Schema["Tool.Schema"]
    Tool --> Actions["Tool.Actions"]
    Schema --> Actions
    Actions --> Tool
    Tool --> Context
    Tool --> Hint
    Context --> Session
    Router --> Session
    Router -->|compile| ServerInfo
    Router --> Stream["Transport.Stream"]
    Stream --> Session
    Stream --> SSE["Transport.SSE"]
    Session --> Telemetry
    Session --> Server

    Validate --> JsonRpc

The consumer surface

Wymcp.Router is the Plug entry point. It owns the routes — POST, the GET SSE stream, DELETE, and a fallthrough that runs nothing and marks its answer — 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, nor a session. 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 — session reference, request metadata, the merged 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 session state itself; it is the read path onto Wymcp.Session. 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 — notably that tool and action are strings, matching 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 application'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.

Wymcp.Server is the optional session-lifecycle behaviour: Wymcp.Server.init/2 when a session becomes ready, Wymcp.Server.terminate/2 at shutdown. It deliberately owns no per-request hook: 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, still compose as host plugs ahead of the mount.

Wymcp.Session is the GenServer holding one session's state: the negotiated version, capabilities, the effective tool list, per-session assigns, and pending server→client requests. It owns session lifetime, the push path to an open stream, and the ordered log-level vocabulary — the one statement of it, from which Wymcp.Context derives its rank map at compile time. It is on this surface because Wymcp.Session.register_tool/2 is a documented consumer entry point, called from a Wymcp.Server callback.

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: which revision each era serves, why 2024-11-05 is refused, and the names of the modern protocol fields. It owns no negotiation policy — the counter-proposal is built where initialize is answered.

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 — session opts, running a tool the way the modern lane runs it, 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 two build-time-compiled protocol schema roots — one per era — and the distilled shape every validation rejection reaches the wire as.

Wymcp.Response owns sending, and the record of every rejection: one primitive per error dialect, 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 a rejection 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 modern lane's two result shapes — 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; era-guarded no-ops on the legacy lane.

Wymcp.ServerInfo owns the server identity map both eras emit — 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.ToolWymcp.Tool.SchemaWymcp.Tool.ActionsWymcp.Tool, whose last leg reads the key list rather than building anything.

Wymcp.Transport.Stream owns the SSE connection for one session: the receive loop run by the GET request process that owns the socket, the mutual monitor with the session, and the one-active-stream-per-session rule.

Wymcp.Transport.SSE owns event framing and nothing else — no process state, no side effects, just the wire format for an already-encoded payload.

The plug lane

Eleven plugs are one story, not eleven: a POST's order of checks. In that order — Wymcp.Plugs.OriginCheck, Wymcp.Plugs.Classify, Wymcp.Plugs.Auth, Wymcp.Plugs.SingletonHeaders, Wymcp.Plugs.Era, Wymcp.Plugs.ProtocolFields, Wymcp.Plugs.HeaderBinding, Wymcp.Plugs.Session, 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.Session are body-bound, and why; and the inline body parsing 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; the same checks run in its GET and DELETE bodies, where there is no pipeline. Each plug's own moduledoc carries its rules; the order, and why it is that order, lives in Wymcp.Plugs.Pipeline.