mcp42 — MCP Server Library (implementation plan)

Copy Markdown

Status: 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.Server with @behaviour; optional server_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 docs builds; 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.