Wymcp.Router (Wymcp v0.4.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.Mcp

Every 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.

Three clauses, each with its own enforcement:

  • unknown implies rejectedinit/1 subtracts option_keys/0 and the framework-only keys from the keys it was handed and raises on whatever is left, so origins: 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, so origin: [], origin: ["http://a"] would serve every Origin under a mount that reads as an allowlist. A framework-only key — one init/1 writes into the built configuration and no mount site declares, :tool_definitions today — is exempt from that subtraction and refused by validate_not_reinitialized!/2 instead, 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.
  • known implies validatedWymcp.RouterOptionInvariantTest derives one cell per key from option_keys/0 and 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_info bullet also carries that option's own key vocabulary, and Wymcp.ServerInfoTest pins 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. 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 the Wymcp.Tool behaviour (required: init/1 raises when the key is absent, and a deliberate help-only server writes tools: []). Wymcp.Help is appended automatically: every server exposes the framework's introspection tool under the reserved name help, and no consumer tool may use that name (init/1 raises). Two tools declaring the same Wymcp.Tool.name/0 are likewise refused at init/1 rather than at request time — a duplicate would make a tools/call ambiguous, and the mount module's compile is the one moment the whole list is visible at once.
  • :auth — module implementing the Wymcp.Auth behaviour (optional, defaults to Wymcp.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 the Bearer challenge in the 401 WWW-Authenticate header (optional; when absent the challenge is bare Bearer). Each {key, value} renders as key="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 9728 resource_metadata pointer and a scope hint. If rendering an entry raises (e.g. a misconfigured MFA), the challenge degrades to bare Bearer for that request and an error naming this option is logged — the 401 contract survives misconfiguration.
  • :server — module implementing the Wymcp.Server behaviour for session lifecycle hooks (optional, defaults to nil). 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; nil and [] 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. 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, included in the initialize response (legacy era) and the server/discover result (modern era) (optional; must be a string or nil)
  • :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 or data: URI), :mime_type (e.g. "image/png"), :sizes (list of "WxH" strings or "any"), and :theme ("light" or "dark"). init/1 validates the keys and stores the option's wire form — the serverInfo partial Wymcp.ServerInfo.encode!/1 returns — at the mount module's compile: an unknown key, a :name/:version key, or a malformed shape raises there, and values pass to the wire verbatim. Per request, name and version from application config join the partial; the legacy lane emits the result in initialize, the modern lane in every result's _meta (optional).

The wire-check invariant

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

__using__(opts)

(macro)

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]
end

The 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.

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 served — the definition a client reads is the one this moment produced, never a per-request rebuild.

The contract that buys it: every callback feeding a definition — Wymcp.Tool.name/0, Wymcp.Tool.description/0, Wymcp.Tool.actions/0 (through the input schema), 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. 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.

call(conn, opts)

Callback implementation for Plug.call/2.

init(opts)

Callback implementation for Plug.init/1.