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", 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:

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.