Wymcp
MCP (Model Context Protocol) server library for Elixir. A Plug-based implementation of the MCP JSON-RPC 2.0 protocol with support for tools and optional Bearer token authentication.
API Changes {: .warning}
This project is a work in progress and the API will change until we reach version 1.0.0.
Duplicated
Originis refused in every configuration {: .warning}Since 0.4.0 the origin check answers HTTP 400 to a request carrying two or more
Originheader lines, whether or not the mount sets:origin. Earlier releases ran that check only under a configured allowlist, so a mount without one answered such a request normally. RFC 6454 already forbids a user agent from sending more than oneOriginheader field — several origins travel space-separated inside a single line, which the duplicate-header check never inspects — so the new 400 answers a broken proxy, not a browser. That 400 fires only on an adapter that preserves repeated header lines: see Supported HTTP adapter below.
Supported MCP protocol versions
Wymcp is a dual-era server: it serves the modern era (2026-07-28) and
the legacy era (2025-11-25) on the same endpoint. The glossary defines what
an era is; which revisions
each era accepts, why 2024-11-05 is refused, and what a client asking for
something else is answered are all documented at Wymcp.ProtocolVersion; how
a request is sorted into a lane is at Wymcp.Plugs.Era.
Supported HTTP adapter
Wymcp is developed and tested against Bandit;
Plug.Cowboy is untested, and the difference is observable. Cowboy folds
repeated header lines into one comma-joined value before Plug sees the
request, so a duplicated header never arrives as a duplicate. Under Cowboy a
mount with no :origin allowlist accepts a duplicated Origin, and a mount
with an allowlist refuses it as a disallowed origin naming the folded value —
neither is the 400 documented here. Wymcp.Plugs.OriginCheck documents what
the origin check answers, Wymcp.Plugs.SingletonHeaders the other
cardinality checks.
Support for another adapter is welcome as a contribution.
Browser clients
Wymcp validates the Origin header — the MCP spec's MUST, and DNS-rebinding
protection against a malicious page reaching a server bound to localhost. It
serves no CORS response headers and no OPTIONS route, so a browser page
cannot call a wymcp mount directly: the preflight a cross-origin fetch sends
reaches no route.
That is the intended posture, not a gap. CORS headers are a grant a
deployment makes to named browser origins, and which origins those are is
deployment data rather than anything MCP describes — so the grant belongs to
the host application, beside the CORS policy it already runs for its other
routes. Every non-browser client (Claude Code, the SDKs, curl) sends no
preflight and reads no CORS header, and reaches a wymcp mount today.
To enable browser access, put a CORS plug in front of the mount — it answers the preflight itself and never reaches wymcp:
pipeline :mcp do
plug CORSPlug, origin: ["https://app.example.com"]
end
scope "/" do
pipe_through :mcp
forward "/mcp", MyApp.Mcp
end
MyApp.Mcp is the mount module from 4. Create your mount module.
Set origin: on the mount as well, so wymcp refuses what the CORS grant does
not cover — see 6. (Optional) Restrict browser origins.
Getting started
1. Add dependency
In mix.exs:
defp deps do
[
{:wymcp, "~> 0.6.0"}
]
end
2. Create a tool
defmodule MyApp.Tools.Calculator do
use Wymcp.Tool
@impl true
def name, do: "calculator"
@impl true
def description, do: "Basic arithmetic"
@impl true
def actions do
%{
add: %{
description: "Add two numbers",
properties: %{
"a" => %{"type" => "number"},
"b" => %{"type" => "number"}
},
required: ["a", "b"],
defaults: %{}
}
}
end
@impl Wymcp.Tool
def run_action(:add, %{"a" => a, "b" => b}, _context) do
{:ok, %{result: a + b}}
end
end
Two framework behaviours a tool author meets next, both documented in full at
their modules: every server exposes a help tool that answers at three levels
(Wymcp.Help), and a tool can suggest follow-up actions by returning hints
(Wymcp.Hint).
3. Add config
In config.exs:
config :wymcp,
name: "My MCP Server",
version: Mix.Project.config()[:version] || "0.1.0"
wymcp attaches one telemetry handler at boot, Wymcp.Telemetry.Logger,
which renders Wymcp.Telemetry's events as structured Logger lines;
which events, at what level and with which keys is that module's to state.
Add logger: false to that block to stop it. Turning it off turns
off the fault lines too: a tool that raises is rescued and answered as an
error result, so nothing about that raise reaches your logs unless a handler
renders it. An app that wants different lines attaches its own handler
against the same events instead.
4. Create your mount module
use Wymcp.Router builds the module your router forwards to. Its options
run through the router's validation while this module compiles, so a
malformed tool fails mix compile instead of the first request to /mcp:
defmodule MyApp.Mcp do
use Wymcp.Router,
tools: [MyApp.Tools.Calculator]
end
Then forward to it in router.ex:
forward "/mcp", MyApp.Mcp
Every other router option, and what each one means, is documented in full at
Wymcp.Router.
5. (Optional) Add authentication
Implement the Wymcp.Auth behaviour and name it in your mount module:
defmodule MyApp.McpAuth do
@behaviour Wymcp.Auth
@impl Wymcp.Auth
def authenticate(conn) do
with ["Bearer " <> token] <- Plug.Conn.get_req_header(conn, "authorization"),
{:ok, user} <- MyApp.Accounts.fetch_user_by_api_token(token) do
{:ok, Plug.Conn.assign(conn, :current_user, user)}
else
_ -> {:error, "Invalid or missing Bearer token"}
end
end
end
use Wymcp.Router,
tools: [MyApp.Tools.Calculator],
auth: MyApp.McpAuth
Authentication runs per request on every MCP route — POST, the GET stream,
and DELETE. Wymcp.Auth documents the contract and the 401 challenge; Wymcp.Router's :www_authenticate option
adds the RFC 9728 discovery hints a spec-following client looks for.
6. (Optional) Restrict browser origins
Name the allowed origins in your mount module:
use Wymcp.Router,
tools: [MyApp.Tools.Calculator],
origin: ["http://localhost:4000"]
origin: is an allowlist of Origin header values — DNS-rebinding protection
for browser-based clients. Wymcp.Plugs.OriginCheck documents which requests
pass and which are refused; a request carrying two or more Origin headers is
refused with or without the option. It does not by itself let a browser page
call the mount — that needs a CORS grant in the host application, described
under Browser clients above.
Documentation
Wymcp's documentation is published at
hexdocs.pm/wymcp — or build it locally with
mix docs:
Wymcp— the map: every module, what it owns, and why it exists, with the request-flow diagram.- Glossary — canonical domain terms and where each one is defined.
- MCP 2026-07-28 overview — the modern era's conformance map.
- MCP 2025-11-25 overview — the legacy era's conformance map.
The last two are maintainer yardsticks for planning wymcp's next revision, and they are filed under Development in the sidebar; the glossary sits beside this README, for every reader.