Wymcp (Wymcp v0.5.0)
View SourceMCP 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 --> 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 --> JsonRpcThe 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 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 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 result decoration — the result type
stamp, the serverInfo _meta, and the cache-hint defaults. 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 — both are
dispatch's, and the full schemas are surfaced on demand by Wymcp.Help —
which is what keeps the tools/list payload compact. 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.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
Ten plugs are one story, not ten: 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.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.