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 Origin is refused in every configuration {: .warning}

Since 0.4.0 the origin check answers HTTP 400 to a request carrying two or more Origin header 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 one Origin header 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", Wymcp.Router, tools: [MyApp.Tools.Calculator]
end

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. Add route

In router.ex:

forward "/mcp", Wymcp.Router,
tools: [MyApp.Tools.Calculator]

5. (Optional) Add authentication

Implement the Wymcp.Auth behaviour and pass it to the router:

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
forward "/mcp", 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

forward "/mcp", 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:

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.