Wymcp. Router
(Wymcp v0.8.7)
View Source
Plug router for the Wymcp MCP server.
Mounting
Build a mount module with use Wymcp.Router, then forward to it:
defmodule MyApp.Mcp do
use Wymcp.Router,
tools: [MyApp.Tools.Events, MyApp.Tools.Tasks],
auth: MyApp.McpAuth,
instructions: "Search docs before answering questions.",
origin: ["http://localhost:4000"],
server_info: %{
title: "My App MCP",
description: "Project management tools",
website_url: "https://myapp.example.com"
},
www_authenticate: [
resource_metadata: {MyAppWeb.Endpoint, :url, []},
scope: "mcp"
]
end
# lib/my_app_web/router.ex
forward "/mcp", MyApp.McpEvery option below belongs at the use site. They run through init/1
while MyApp.Mcp compiles, so a configuration this module refuses aborts
mix compile instead of answering a 500 on the first request to /mcp;
__using__/1 documents the mount module the macro generates. Only
:tools is required.
Options
The option list must be a keyword list,
every router option in it is shape-validated at the registration moment,
and the option key set is closed there too — an unknown key is refused.
That rule is the router-option invariant.
An option is left unset by omitting its key, never by writing nil.
Five clauses, each with its own enforcement:
- non-keyword implies rejected —
init/1tests the option list withKeyword.keyword?/1before it reads any key, and refuses anything else whole, showing the term it was handed: a map, a list holding a bare atom or a string key, an improper list. A map with atom keys converts mechanically, so a map's refusal also namesEnum.to_list/1. Running first means a map carrying a typo'd key is refused as a map, never blamed on the key.Wymcp.RouterOptionInvariantTestholds one cell for this clause, with one test per term in@non_keyword. - unknown implies rejected —
init/1subtractsoption_keys/0and the framework-only keys from the keys it was handed and raises on whatever is left, soorigins:is refused at the registration moment rather than silently leaving the allowlist unset. The same check refuses a documented key given twice: every reader takes the first occurrence, soorigin: [], origin: ["http://a"]would serve every Origin under a mount that reads as an allowlist. A framework-only key — oneinit/1writes into the built configuration and no mount site declares,:tool_definitionstoday — is exempt from that subtraction and refused byvalidate_not_reinitialized!/2instead, so it is diagnosed as a re-initialization rather than as a typo. The exemption stays closed by construction: the refusal iterates the same key list, and each key carries its refusal text in@framework_option_refusals, so a key cannot join the exemption without its own detector. - nil implies rejected —
init/1refuses a router option key written withnil, naming every such key in one refusal. A writtennilis ambiguous — unset on purpose, or a config lookup that came back empty — and for:originand:auththe unset reading would switch a protection off, so it fails closed. The check runs after the key checks and before any option's own validator, so a new option is covered by joining the set, with nonilclause of its own to remember. The clause is key level: what a value may hold inside it stays each option's own rule.Wymcp.RouterOptionInvariantTestderives onenilcell per key fromoption_keys/0. - known implies validated —
Wymcp.RouterOptionInvariantTestderives one cell per key fromoption_keys/0and asserts each one rejects a wrong-type value, so an option joining the set without a validator fails that test rather than shipping unchecked. - restated implies pinned — the catalogue below carries a bullet for
every key in
option_keys/0, and that same test derives the check from the same set, so an option that has a validator and a table row still fails until it is documented. Membership only, with one exception: what a bullet says about its option is otherwise a reader's job, but the:server_infobullet also carries that option's own key vocabulary, andWymcp.ServerInfoTestpins every accepted key and icon key to it — trimming that bullet fails there, not here.
Validation is key-deep: shapes are checked and values pass to the wire
verbatim, so a value typo stays visible client-side instead of aborting
the build. Whether a value can be sent is itself a shape question, and
is answered here too: init/1 encodes every stored artifact that reaches
a client — each tool's definition, the serverInfo partial, :instructions
— and refuses the build when the encoder refuses one, so no mount stores a
value no client could ever receive. That covers what the mount stores and
nothing else: Wymcp.ServerInfo.build/1 reads name and version from
application config per request, after this door has closed, and its own
moduledoc states what that costs. One check warns rather than raises —
:auth reports a module that does not declare its behaviour through
IO.warn/1, because a module can satisfy the contract without declaring
it. Where the registration moment is the mount module's compile, that
diagnostic fails a build running --warnings-as-errors; under a direct
forward it reaches the running server's stderr instead.
:tools— list of modules implementing theWymcp.Toolbehaviour (required:init/1raises when the key is absent, and a deliberate help-only server writestools: []).Wymcp.Helpis appended automatically: every server exposes the framework's introspection tool under the reserved namehelp, and no consumer tool may use that name (init/1raises). Two tools declaring the sameWymcp.Tool.name/0are likewise refused atinit/1rather than at request time — a duplicate would make atools/callambiguous, and the mount module's compile is the one moment the whole list is visible at once. An entry that is not an atom — a string, a tuple, a number — is refused there too, naming the entry, before any tool module is loaded.:auth— module implementing theWymcp.Authbehaviour (optional, defaults toWymcp.Auth.Noop). A non-module value raises; a module that does not declare the behaviour warns:www_authenticate— keyword list of RFC 6750 auth-params appended to theBearerchallenge in the 401WWW-Authenticateheader (optional; when absent the challenge is bareBearer). Each{key, value}renders askey="value"with quoted-string escaping. A value may be a{module, function, args}tuple resolved per request — use this when the value is only known at runtime (e.g. a public URL from runtime config), since a mount module's options are evaluated while it compiles. Typical MCP use: an RFC 9728resource_metadatapointer and ascopehint. If rendering an entry raises (e.g. a misconfigured MFA), the challenge degrades to bareBearerfor that request and an error naming this option is logged — the 401 contract survives misconfiguration.:origin— list of allowed Origin header values for DNS rebinding protection (optional, defaults to allowing all origins). Must be a list of strings; omit the key, or pass[], to allow every origin. A request with no Origin header passes the check even when an allowlist is configured — non-browser clients (curl, SDKs) do not send one. A request carrying two or more Origin headers is refused whether or not this option is set:instructions— a string that guides how an LLM should interact with this server's tools, emitted in theserver/discoverresult (optional; must be a string). Consumer-authored text, governed by the contract at its definition home, theWymcp.Toolmoduledoc, section "Consumer-authored text".:server_info— a map of optional server identity fields displayed by MCP clients. Accepted keys::title(human-readable name),:description,:website_url, and:icons. Each icon is a map whose accepted keys are:src(URL ordata:URI),:mime_type(e.g."image/png"),:sizes(list of"WxH"strings or"any"), and:theme("light"or"dark").init/1validates the keys and stores the option's wire form — the serverInfo partialWymcp.ServerInfo.encode!/1returns — at the mount module's compile: an unknown key, a:name/:versionkey, or a malformed shape raises there, and values pass to the wire verbatim. Those values are consumer-authored text, governed by the contract at its definition home, theWymcp.Toolmoduledoc, section "Consumer-authored text". Per request,nameandversionfrom application config join the partial, and the result rides every result's_meta(optional).
The wire-check invariant
Every non-fallthrough route — POST, the one served verb — runs every wire
check before the request reaches the body-bound plugs, in the order
wire_checks/0 declares: the origin check (Wymcp.Plugs.OriginCheck),
the auth check (Wymcp.Plugs.Auth), then the singleton-header check
(Wymcp.Plugs.SingletonHeaders). This rule is the wire-check
invariant, and this section is the one place the checks are enumerated
in prose: Wymcp.WireCheckInvariantTest pins the enumeration to the
list, so every other page points here instead of restating it. The
ordering is load-bearing: 401/403 rejections win over every answer that
reads the body, so an unauthenticated caller learns nothing about what
the server would have said. The origin check runs before the others
because nothing has validated Origin when it runs, which is also why
that header's duplicate arm lives in the origin check rather than in the
singleton-header check. The fallthrough
— every request no route serves — runs no wire check and answers HTTP 405
with an Allow: POST header for a verb this module does not serve, the
spec's answer for a GET or DELETE at a server that speaks this revision
alone, and 404 for a POST at a path it does not serve; it marks its answer
either way, so the sweep tells it from a served route. The verbs this module does serve are declared in
served_verbs/0 — the served verbs,
which the same test holds to the routes in both directions.
POST runs the checks inside Wymcp.Plugs.Pipeline's chain, interleaved
with the parse step and message classification; that module owns the full
order and the reasons for it, and this module does not restate them — the
test holds that chain to the list. A wire check's rejection speaks the
JSON-RPC error dialect — with the 401
WWW-Authenticate challenge — and carries the
rejection mark naming the check, set
by the shared rejection sender in Wymcp.Response. This module sends no
rejection of its own: the fallthrough's answer is no rejection, since no
check refused anything, and it goes through no sender.
flowchart TD
subgraph Router
R[Wymcp.Router] --> POST["POST / → Pipeline"]
R --> FT["any other verb → fallthrough, 405<br/>POST elsewhere → fallthrough, 404"]
end
subgraph External
POST --> P["Plugs.Pipeline (wire checks inside)"]
end
Summary
Functions
Builds a mount module: the consumer-owned module that mounts wymcp, and the one documented mount shape.
Callback implementation for Plug.call/2.
Callback implementation for Plug.init/1.
Functions
Builds a mount module: the consumer-owned module that mounts wymcp, and the one documented mount shape.
defmodule MyApp.Mcp do
use Wymcp.Router,
tools: [MyApp.Tools.Calculator]
endThe options are this module's init/1 options, and they run through it
while MyApp.Mcp compiles — so the
option list and every option in it are
shape-validated there and an unknown option key is refused there, and no
build artifact can serve a broken endpoint. That rule is the
router-option invariant; the per-option contracts, including the one
check that warns rather than raises, are the Options section's. The built
configuration is stored in the mount module and handed back per request as
a constant.
Your tool modules' callbacks run at that moment, and what they return is
what the server serves: init/1 validates the tools and builds each one's
tools/list definition there, storing it beside them in the configuration.
What was validated is what is sent — the definition a client reads is
the one this moment produced, never a per-request rebuild, and it was
encoded here to prove it can travel.
The contract that buys it: every callback feeding a definition —
Wymcp.Tool.name/0, Wymcp.Tool.description/0,
Wymcp.Tool.input_schema/0, Wymcp.Tool.actions/0,
Wymcp.Tool.output_schema/0, Wymcp.Tool.title/0 and
Wymcp.Tool.annotations/0 — must be callable
with no runtime state. They run during your build: mix compile runs before
config/runtime.exs and before any supervision tree exists, so a callback
reading Application.fetch_env!/2 or calling a GenServer fails that
build with its own error — at this module's file, or at the tool module's
own when use Wymcp.Tool checked it first. A non-raising read —
Application.get_env/3 with a default, or System.get_env/1 — fails
nothing: it silently returns whatever the compile environment held, and
that value is frozen into the served definition until the next build. For
a value known only at runtime, :www_authenticate's
{module, function, args} form is the supported way to defer one to request
time.
Editing a tool module recompiles the mount module, so the validation does not
go stale — as long as the compiler can see which modules :tools names.
Naming them here, or calling a function whose own module names them, records
a compile-time dependency and mix follows it. Reaching the list through
Application.compile_env/2 records nothing: the compiler sees no module
reference at all, and this module keeps its stored configuration through a
tool edit.
The generated init/1 accepts only []. Options at the mount site would
read as configuration the server does not actually apply, so they raise.
Callback implementation for Plug.call/2.
Callback implementation for Plug.init/1.