Wymcp. Router
(Wymcp v0.2.0)
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,
server: MyApp.McpServer,
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
Every router option 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.
Two clauses, each with its own enforcement:
- unknown implies rejected —
init/1subtractsoption_keys/0from the keys it was handed and raises on whatever is left, soorigins:is refused at the registration moment rather than silently leaving the origin check off. 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. - 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.
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. Two of the checks warn rather than raise — :auth and
:server report 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.: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.:server— module implementing theWymcp.Serverbehaviour for session lifecycle hooks (optional, defaults tonil). A non-module value raises; a module that does not declare the behaviour warns:origin— list of allowed Origin header values for DNS rebinding protection (optional, defaults to allowing all origins). Must be a list of strings;niland[]are the allow-all shapes. A request with no Origin header passes the check even when an allowlist is configured — non-browser clients (curl, SDKs) do not send one:instructions— a string that guides how an LLM should interact with this server's tools, included in the initialize response (legacy era) and theserver/discoverresult (modern era) (optional; must be a string ornil):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. Per request,nameandversionfrom application config join the partial; the legacy lane emits the result ininitialize, the modern lane in every result's_meta(optional).
Every non-fallthrough route — POST, GET (the SSE stream), DELETE — runs
all three wire checks before the request touches any session state: 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 its ordering is load-bearing: 401/403 rejections win over the session
answers, so an unauthenticated caller learns nothing about session
existence — and a rejected request neither resets the session's idle timer
nor displaces its registered SSE stream. The origin check stays first
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 (any other verb) runs no checks and
touches nothing.
POST runs the checks as the first, fourth, and fifth plugs of
Wymcp.Plugs.Pipeline's chain; that module owns the full order and the
reasons for it, and this module does not restate them. Modern-classified
requests and notifications pass through Wymcp.Plugs.Session untouched, and
a JSON-RPC response resolves its session on either lane. GET and DELETE run
the same three checks in the route body, before the Mcp-Session-Id header
is read. A wire check's
rejection speaks the error dialect of the route it runs on: the JSON-RPC
dialect on POST, the plain-JSON dialect (%{error: message}) on GET and
DELETE — with the 401 WWW-Authenticate challenge on every method.
flowchart TD
subgraph Router
R[Wymcp.Router] --> POST["POST / → Pipeline"]
R --> WC["GET / DELETE / → wire checks"]
WC --> GET["GET / → SSE stream"]
WC --> DELETE["DELETE / → terminate"]
end
subgraph External
POST --> P["Plugs.Pipeline (wire checks inside)"]
WC --> OC[Plugs.OriginCheck]
WC --> AU[Plugs.Auth]
WC --> SH[Plugs.SingletonHeaders]
GET --> S[Session]
GET --> ST[Transport.Stream]
DELETE --> S
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 every option is 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 two checks that warn rather than
raise, are the Options section's. The built configuration is stored in
the mount module and handed back per request as a constant.
Two of your tool modules' callbacks run at that moment: Wymcp.Tool.name/0
and Wymcp.Tool.actions/0, which the validation chain calls. mix compile
runs before config/runtime.exs, so a tool that builds its action schemas
from runtime configuration is validated against the compile environment's
values — while the schema it actually serves is rebuilt from
Wymcp.Tool.actions/0 per request, once that configuration is loaded. For
such a tool the guarantee above does not hold: what was checked is not what
is served. Keep those two callbacks free of runtime configuration so the two
agree; for :www_authenticate, that option's {module, function, args} form
is the supported way to defer a value 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.