mcp42 — MCP Server Library (implementation plan)
Copy MarkdownStatus: complete (Tasks 1-5) Date: 2026-09-06 Spec target: MCP Streamable HTTP (2025-06-18) — https://modelcontextprotocol.io/specification/2025-06-18
Goal
A standalone Hex-publishable Elixir library implementing the MCP server side,
with a clean boundary: mcp42 owns protocol + transport only. Host applications
(rproxy, davver, search_frontend) own tools, auth, and business logic via the
Mcp42.Server behaviour.
Source material (read-only reference): pim42 davver_web McpController (~617 lines, git HEAD b9b2bc4 — working tree has unresolved merge conflicts, do NOT copy from it). What is worth reusing conceptually: JSON-RPC dispatch shape, tool list/result format, SSE keepalive pattern. What is replaced: legacy dual-endpoint HTTP+SSE transport (2024-11-05) → Streamable HTTP (2025-06-18).
Non-goals (v1)
- resources / prompts server capabilities (tools only — rproxy needs nothing else yet)
- MCP client role (claudestine toolbox already covers client-side discovery)
- sampling / roots / elicitation
- OAuth — auth is the host's pipeline concern (fail-closed Bearer example provided in docs)
Architecture
Mcp42.Protocol — pure: JSON-RPC 2.0 encode/decode, error codes, batch handling
Mcp42.Server — behaviour (use Mcp42.Server): tools/0 + handle_tool/3 (+ optional
server_info/0, list_changed notification hook)
Mcp42.Dispatcher — pure: maps JSON-RPC request -> Server callback -> JSON-RPC response
(initialize, notifications/*, ping, tools/list, tools/call)
Mcp42.Session — GenServer per Streamable-HTTP session: id (UUID), inbox for
server-initiated messages, protocol version negotiated
Mcp42.SessionRegistry — ETS-backed registry, MCP-Session-Id -> session pid
Mcp42.Plug — Streamable HTTP endpoint: POST (json or SSE response),
GET (SSE stream), DELETE (terminate session); Origin header
validation (2025-06-18 security requirement)Dependency policy: core deps only plug + jason (both optional-ish; phoenix NOT required). No app logic ever enters these modules.
Tasks (TDD — red first, always)
Task 1 — Protocol core (Mcp42.Protocol)
Pure functions, exhaustive tests first:
- request/response/notifications encoding (jsonrpc:"2.0", id round-trip incl. string ids)
- error constructors: -32700 parse, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal; MCP-specific ones live in Dispatcher
- batch validation (empty array -> single invalid-request error response)
Task 2 — Server behaviour + Dispatcher (initialize/ping/tools)
use Mcp42.Serverwith@behaviour; optionalserver_info/0- Dispatcher: initialize (protocol negotiation 2025-06-18 vs 2024-11-05 vs 2024-10-07), notifications/initialized -> :notification (no response), ping -> {}, tools/list -> host tools (isError absent), tools/call -> host handle_tool
- tool result mapping: {:ok, text} / {:ok, :list, content_items} / {:error, msg} (isError true) / {:error, code, msg}
- unknown tool -> tool result with isError:true (spec: tool errors are RESULTS, not JSON-RPC errors — unlike pim42 which returned -32602)
- host crash -> {:error, "internal error"} JSON-RPC response, never a raised 500
Task 3 — Session + registry
- Mcp42.Session GenServer: init generates UUID id, notifies registry, traps exit; push/2 for server-initiated messages (bounded inbox, drop-oldest? NO — fail session instead: spec silent, we choose crash-free backpressure)
- Mcp42.SessionRegistry: ETS public read, GenServer writes, monitors pids, :DOWN cleanup, lookup/1
Task 4 — Streamable HTTP Plug (POST/GET/DELETE)
POST: Accept negotiation (application/json only, or +text/event-stream); body parse -> Protocol.decode -> Dispatcher (creating session on initialize, Mcp-Session-Id header thereafter) -> JSON response or SSE (multi response via SSE when Accept includes event-stream and >1 message pending; single message may still be JSON). 202 + SSE for notifications-only requests (no response body expected). GET: 405 unless server supports server-initiated messages; else SSE stream with keepalive comments, session inbox -> message events. DELETE: terminate session -> 204. Origin validation: reject cross-origin POST/GET with 403 unless allowed_origins configured (localhost default allow for dev). Tests: plug_test using Phoenix.ConnTest-style with Plug.Test — full request/response cycles incl. header contract (Mcp-Session-Id echo), Accept matrix, origin matrix.
Task 5 — Reference tools + docs
- Example server module in docs (rproxy-flavoured pseudocode stays OUT; use a generic echo/weather example)
- README + moduledocs; CHANGELOG.md created
mix docsbuilds; credo-clean if we add credo (optional)
Task 6 — rproxy Phase H integration (separate repo, after mcp42 usable)
NOT part of mcp42. First admin tools against proxy-engine internal API: list routes, get stats, tunnel status, WAF rule listing. Uses RproxyWeb auth pipeline + Mcp42.Plug.
Verification gates
Every task: mix test green, format clean, no warnings. Tag v0.1.0 when Tasks 1-5 done.
Change log
- 2026-09-06 plan drafted.