Current section

Files

Jump to
wymcp README.md
Raw

README.md

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

<div data-toc />

> ### API Changes {: .warning}
> This project is a work in progress and the API will change until we reach version 1.0.0.

## Getting started

### 1. Add dependency

In `mix.exs`:

```elixir
defp deps do
  [
    {:wymcp, "~> 0.8.0"}
  ]
end
```

### 2. Create a tool

```elixir
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`:

```elixir
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 faults is contained
([*fault containment*](https://hexdocs.pm/wymcp/glossary.html#fault-containment))
and answered as an error result, so nothing about that fault 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`:

```elixir
defmodule MyApp.Mcp do
  use Wymcp.Router,
    tools: [MyApp.Tools.Calculator]
end
```

Then forward to it in `router.ex`:

```elixir
forward "/mcp", MyApp.Mcp
```

The forward must receive the request body unread. The mount reads the body
itself, so that a body it refuses — malformed, too large, or sent as another
type — is answered with an MCP error and recorded as a
[*rejection*](https://hexdocs.pm/wymcp/glossary.html#rejection). A body parser
that runs ahead of the mount reads the body first and answers what it refuses
in its own way, and wymcp never sees that request. A generated Phoenix
endpoint runs `Plug.Parsers` on every request, so in `endpoint.ex` replace
`plug Plug.Parsers` with a function plug that skips the path you forward:

```elixir
plug :parse_body

@parsers Plug.Parsers.init(
           parsers: [:urlencoded, :multipart, :json],
           pass: ["*/*"],
           json_decoder: Phoenix.json_library()
         )

# The MCP mount reads its own body.
defp parse_body(%Plug.Conn{path_info: ["mcp" | _]} = conn, _opts), do: conn
defp parse_body(conn, _opts), do: Plug.Parsers.call(conn, @parsers)
```

`Plug.MethodOverride` and the endpoint's other plugs stay where they are,
with one exception: a plug that reads the query string ahead of the forward
answers a malformed or over-long one itself, in its own way. A generated
endpoint's `Phoenix.LiveDashboard.RequestLogger` does so through its
`param_key`; keep its `cookie_key` alone.

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:

```elixir
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
```

```elixir
use Wymcp.Router,
  tools: [MyApp.Tools.Calculator],
  auth: MyApp.McpAuth
```

Authentication runs per request on every POST — the one verb the mount
serves. `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:

```elixir
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, described
under *Browser clients* above.

## Supported MCP protocol versions

Wymcp speaks the **modern era** of MCP only — the `2026-07-28` revision,
with its per-request protocol fields and no `initialize` handshake, no
sessions and no server-opened stream; the eras are defined on the spec's
[versioning page](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning).
A client of an earlier revision is told so rather than served: an
`initialize` is answered HTTP 400 with JSON-RPC `-32022` naming the served
version, a request without the protocol fields with `-32602`, a stray
`Mcp-Session-Id` or `Last-Event-ID` header is ignored and no session id is
ever minted, and a GET or DELETE answers 405 with `Allow: POST`.
`Wymcp.ProtocolVersion` is the one statement of the version served, and
`Wymcp.Plugs.ProtocolFields` documents the two answers. A Claude Desktop or
Cowork configuration that reaches a wymcp server through `mcp-remote` needs
that bridge's `--protocol auto` flag, which makes it speak this revision.

## Supported HTTP adapter

Wymcp is developed and tested against [Bandit](https://hexdocs.pm/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, 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:

```elixir
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*.

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


## Documentation

Wymcp's documentation is published at
[hexdocs.pm/wymcp](https://hexdocs.pm/wymcp) — or build it locally with
`mix docs`:

- [`Wymcp`](https://hexdocs.pm/wymcp/Wymcp.html) — the map: every module,
  what it owns, and why it exists, with the request-flow diagram.
- [Glossary](https://hexdocs.pm/wymcp/glossary.html) — canonical domain
  terms and where each one is defined.
- [MCP 2026-07-28 overview](https://hexdocs.pm/wymcp/mcp-spec-2026-07-28-overview.html)
  — the conformance map.

The last one is a maintainer yardstick for planning wymcp's next revision,
and it is filed under **Development** in the sidebar; the glossary sits
beside this README, for every reader.