Add a production Streamable HTTP MCP endpoint with Hex ExMCP 1.1, Bearer auth on every protected method, and the Phoenix/Bandit adapters proven in goodviews.
Do not use Anubis. Do not use use ExMCP.Server / deftool (removed
fork DSL). Do not generate a thin Handler, RequestHandler, RequestContext,
AuthSessions, SSEHub, or a CorsPlug that intercepts notifications. Target
{:ex_mcp, "~> 1.1.1"} from azmaveth/ex_mcp.
Prerequisites
- Phoenix 1.7+ (1.8 preferred) with PostgreSQL
- API keys already exist (
/bootstrap-api-keysor/bootstrap-openapi):Accounts.verify_api_token/1orAccounts.authenticate_api_key/1- An
ApiKeywith eitherscopesortype(:public/:private)
- Bandit (Phoenix default). Cowboy does not need the SSE stream-owner adapter.
App Name Detection
Detect OTP app and modules from mix.exs. Replace MyApp / my_app /
MyAppWeb / my_app_web in every reference template.
Architecture (required)
Copy this layout. A naive forward "/mcp", ExMCP.HttpPlug, handler: MyServer
looks simpler and fails under Inspector, Bandit SSE, Phoenix parsers, and
per-request auth.
lib/my_app/mcp_server.ex # DSL tools; not in the supervisor
lib/my_app/mcp/transport.ex # HttpPlug opts, handler_opts, principal/tenant
lib/my_app_web/mcp/auth_plug.ex # Bearer, then VaryOrigin + HttpPlug/SSE
lib/my_app_web/mcp/vary_origin.ex # Vary: Origin + expose session headers
lib/my_app_web/mcp/sse.ex # Bandit-safe GET SSE owner loop
lib/my_app_web/mcp/sse_conn.ex # chunk/2 on the request process
lib/my_app_web/mcp/error.ex # JSON 401/403
lib/my_app_web/plugs/cache_body.ex # stash complete raw body for HttpPlug
Request flow:
POST/GET/DELETE /mcp
→ Host allow-list (403 before auth)
→ AuthPlug (Bearer on GET, POST, DELETE; OPTIONS is credential-free)
→ assigns.mcp_api_key
→ VaryOrigin
→ ExMCP.HttpPlug (handler: MCPServer, handler_opts from assigns)
→ GET SSE: MCP.SSE chunks on the request process
→ MCPServer.init/1 state (temporary per-request GenServer)
Why this shape
| Decision | Why |
|---|---|
handler: MCPServer, not a thin Handler |
ExMCP 1.1 starts atom handlers as temporary request-local GenServers. A module without start_link/1 is no longer a valid direct-dispatch trick. |
Identity in handler_opts → init/1 state |
Tools read state.api_key. Process-dictionary identity is invisible to the handler GenServer. |
principal_id / tenant_id callbacks |
Native sessions bind to the authenticated subject. A session header is an extra binding, never a substitute credential. |
CacheBody concatenates {:more, chunk} |
Phoenix consumes the adapter body; HttpPlug reads assigns[:raw_body]. The last chunk alone is not the document. |
No :api / accepts ["json"] pipeline |
SSE uses Accept: text/event-stream. POST requires application/json. |
Native CORS + VaryOrigin |
ExMCP validates Origin and echoes an allowlisted value. It does not set Vary: Origin or expose mcp-session-id. Never Access-Control-Allow-Origin: * on authenticated MCP. |
| Bandit-safe SSE wrapper | ExMCP.HttpPlug.SSEHandler still owns the session; the app only chunk/2s on the stream owner. |
protocol_mode: :legacy_only |
Production default until clients are proven. :prefer_modern is a runtime canary, not the scaffold default. |
Compile-time tool DSL |
Tools exist even when a client skips the handshake. |
| Bearer on GET | EventSource-without-Authorization is not supported. Disable GET SSE rather than weaken auth. |
Phase 0: Discovery
Before writing files, record:
- App modules from
mix.exs - Existing MCP (
Grep("ExMCP|Anubis|/mcp")) — adapt, do not duplicate - API key verify function and whether keys have
scopesortype - Endpoint
Plug.Parsersoptions - Whether
accountsexist (optional tenant on handler state)
Present:
Existing: …
Missing: …
Auth: verify_api_token | authenticate_api_key; scopes | public/private
Ask before overwriting an existing MCP stack.
Implementation Order
Execute in this order. Read the linked file before writing those modules.
Phase 1: Dependency
Read references/integration.md
- Add
{:ex_mcp, "~> 1.1.1"}andmix deps.get - Confirm
:ex_mcpstarts (Hex application). Do not callExMCP.HttpPlug.start_link/1. Do not startMCPServer.
Phase 2: Parsers + transport
Read references/http.md (CacheBody) and references/integration.md (Transport, config).
CacheBodybody_reader on the endpoint parsers (concatenate every chunk)MyApp.MCP.Transport(plug_opts/0,handler_opts/1, principal/tenant):mcpand:mcp_allowed_hostsconfig (:legacy_only, Origin allowlist)
Phase 3: Server + HTTP
Read references/server.md then the plug templates in references/http.md
MyApp.MCPServerwith DSL tools (ping,example_list,example_get,example_create). Identity frominit/1state. Customize example tools to a real context.Error,VaryOrigin,SSEConn,SSE,AuthPlug
Phase 4: Router
Read references/integration.md
:mcppipeline (host allow-list only — no:api)GET/POST/DELETE/OPTIONS /mcp→AuthPlugoutsidedev_routesmcp_allowed_hostsincludeswww.example.comin test
Phase 5: Tests + clients
Read references/tests.md
MCPCase+ ConnTest handshake (initialize, notify 202, tools/list, ping)- Point Claude CLI / Inspector at
/mcpwith the API key and Bearer on every method, including GET SSE
Adding a tool later
In MCPServer:
tool "my_tool", "One-line description the model sees" do
title("My Tool")
input_schema(%{
type: "object",
properties: %{id: %{type: "string"}},
required: ["id"],
additionalProperties: false
})
run(fn args, state ->
MyApp.MCPServer.with_scope("my_domain:read", state, fn _api_key ->
id = arg(args, :id)
{:ok, %{content: [json(%{ok: true, id: id})]}, state}
end)
end)
end
Register nothing else. Compile-time tool is the registration. Let ExMCP
emit InitializeResult — do not special-case initialize.
Scope names
domain:action — examples:read, examples:write, examples:delete.
If ApiKey has no scopes, map :public → read, :private → read+write.
Production
- HTTPS only.
mcp_allowed_hostsis the public host(s) only. - Keep
protocol_mode: :legacy_onlyuntil Inspector/Claude/Cursor (and any advertised client) complete a handshake against the deployed endpoint. Flip to:prefer_modernas a runtime canary (MCP_PROTOCOL_MODE), not in the same change that scaffolds the handler. - ExMCP sessions and SSE streams are node-local. Launch with one MCP-serving instance. Do not describe ETS/Registry/DNSCluster as a shared session store. Horizontal scale needs a later affinity or distributed-store design.
- Reverse proxies:
x-accel-buffering: nois already on the SSE path. - Log tool name +
api_key.id(or user/account ids), result class, protocol version. Never log access tokens, API keys, rawmcp-session-id, or unrestricted tool arguments. - Do not put
/mcpbehinddev_routes.
The goodviews runbook is the production reference for topology, canary
gates, and telemetry: app/docs/mcp-production-runbook.md. Copy the
constraints, not the PageFoo module names.
Troubleshooting
Read references/troubleshooting.md when a client fails handshake, returns 404 in prod, hangs on SSE, gets Origin 403, or tools see no API key.
Checklist
-
{:ex_mcp, "~> 1.1.1"}in mix.exs (notanubis_mcp, notfire/ex-mcp) -
CacheBodyconcatenates{:more, chunk}on endpoint parsers -
MCP.Transportbuilds HttpPlug opts; MCPServer not in the supervisor -
MCPServer.init/1copieshandler_optsinto state; tools read state -
AuthPlug→VaryOrigin→HttpPlug/ Bandit SSE wrapper - Bearer required on GET, POST, DELETE; OPTIONS credential-free
-
/mcpGET/POST/DELETE/OPTIONS outsidedev_routesand outside:api - Host allow-list 403 +
allowed_origins(no wildcard on authenticated MCP) -
protocol_mode: :legacy_only,legacy_http_sse: true - ConnTest: initialize, notify 202, tools/list, ping, missing Bearer 401
- Claude CLI or Inspector pointed at
/mcpwith a Bearer key
OAuth 2.1 + PKCE for Inspector/Claude (account-scoped resource, DCR, consent):
/bootstrap-mcp-oauth.