ExMCP Architecture Guide

Copy Markdown View Source

ExMCP is organized around protocol boundaries: clients, servers, transports, HTTP Plug integration, ACP, authorization, and internal protocol helpers. Public APIs stay small; cross-cutting work is kept at transport or Plug boundaries.

This guide describes the 1.x architecture. The accepted direction for per-server runtime ownership, bounded handler scheduling, and replaceable state stores is tracked separately in the ExMCP 2.0 roadmap.

Public Layers

MCP Client

ExMCP.Client owns the client process, protocol-era establishment, request IDs, server capability state, retries, and request/response formatting.

Client operation modules under lib/ex_mcp/client/operations/ keep tool, resource, and prompt calls focused while ExMCP.Client.ConnectionManager normalizes transport startup.

MCP Server

Server implementations use ExMCP.Server.Handler. Most applications should add ExMCP.Server.DSL for declarative tools, resources, and prompts:

defmodule MyServer do
  use ExMCP.Server.Handler
  use ExMCP.Server.DSL

  tool "echo", "Echo input" do
    param :message, :string, required: true

    run fn %{message: message}, state ->
      {:ok, %{content: [%{type: "text", text: message}]}, state}
    end
  end
end

ExMCP.Server.HandlerServer is the transport-aware process for in-memory and BEAM-local handler execution. HTTP and stdio servers are started through ExMCP.Server.Transport or the DSL-generated start_link/1.

Transports

Transport modules implement ExMCP.Transport:

BEAM-local MCP is selected with transport: :beam and requires a server PID:

{:ok, server} = MyServer.start_link(transport: :beam)  # or HandlerServer.start_link(handler: MyHandler, ...)
{:ok, client} = ExMCP.Client.start_link(transport: :beam, server: server)

The removed :native alias and direct dispatcher API are not part of the 1.0 public architecture.

HTTP Plug

ExMCP.HttpPlug is the HTTP server boundary. Request parsing, session resolution, CORS/origin handling, response shaping, and SSE handling are split under lib/ex_mcp/http_plug/.

Use normal Phoenix/Plug composition for HTTP edge concerns:

pipeline :mcp do
  plug ExMCP.Plugs.DnsRebinding
  plug MyApp.AuthenticateMCP
end

scope "/mcp" do
  pipe_through :mcp

  forward "/", ExMCP.HttpPlug,
    handler: MyApp.MCPServer,
    server_info: %{name: "my-app", version: "1.0.0"}
end

ACP

ACP modules live under lib/ex_mcp/acp/. ExMCP.ACP.Client controls ACP agents, ExMCP.ACP.Agent exposes native Elixir ACP agents, and adapter modules bridge external CLIs such as Claude Code, Codex, and Pi.

ACP pooling is intentionally left to consumers. ExMCP provides the protocol, transport, adapter, and native-agent building blocks.

Protocol Era Model

ExMCP treats MCP 2025-11-25 and earlier as the legacy era and MCP 2026-07-28 as the modern era. This is an architectural boundary, not just a version comparison: handshake, metadata, result envelopes, notifications, and HTTP state all change together. A connection is established in one era and is never allowed to mix wire shapes afterward.

flowchart TD
  A["Client starts with protocol_mode"] --> B{"Opening policy"}
  B -->|"legacy_only"| C["initialize"]
  B -->|"prefer_legacy"| C
  B -->|"prefer_modern"| D["server/discover probe"]
  B -->|"modern_only"| D
  C -->|"success"| E["Pin legacy era"]
  C -->|"eligible protocol failure + live transport"| D
  D -->|"valid discovery"| F["Pin modern era"]
  D -->|"evidence of legacy + fallback allowed"| C
  D -->|"ambiguous, transport, or strict-mode failure"| G["Fail connection"]
  E --> H["Legacy request/result rules"]
  F --> I["Modern metadata/result rules"]

The four modes are intentionally policies rather than protocol versions:

ModeEnabled erasClient opens withAutomatic fallback
:legacy_onlyLegacyinitializeNever
:prefer_legacyBothinitializeTo modern only after an eligible protocol failure on a live transport
:prefer_modernBothserver/discoverTo legacy only with positive compatibility evidence on a live transport
:modern_onlyModernserver/discoverNever

On the server, both :prefer_legacy and :prefer_modern accept either era; the preference controls advertised version ordering. HandlerServer and stdio connections pin on a valid modern request or legacy initialize. HTTP remains stateless in the modern era, so ExMCP.Server.RequestContext derives and validates the era on each request instead.

Era responsibilities

  • ExMCP.Internal.VersionRegistry is the source of truth for known revisions, their era, enabled versions, and preference order. The zero-arity legacy helpers deliberately retain rc.5 behavior during the RC soak.
  • ExMCP.Client.ConnectionManager applies the selected policy. ExMCP.Client.EraProbe owns the bounded, side-effect-free server/discover probe.
  • ExMCP.Client.EraCache keys observations by transport identity. Modern observations do not expire and cannot be replaced by automatic downgrade; legacy observations expire so an upgraded peer is eventually probed again.
  • ExMCP.Server.RequestContext separates modern _meta from application parameters, validates the configured mode and method availability, and exposes a single context to dispatch.
  • ExMCP.Protocol.ResultEnvelope enforces modern resultType while preserving the legacy result shape. MRTR continuation state, cache hints, subscriptions, and modern Tasks remain protocol-layer concerns rather than transport state.
  • Transports implement era-specific framing only. When HTTP settles modern it discards legacy session state and disables the standalone GET stream.

This separation prevents a failed modern probe from becoming an unsafe silent downgrade. Fallback requires both a recognized compatibility signal and a still-usable transport. A cached modern peer failing its next probe is surfaced as an error until the operator clears that observation or changes policy.

Internal Functional Cores

Pure transformation and validation logic is kept separate from process and I/O boundaries:

  • Internal protocol and version modules handle message construction, parsing, and version rules.
  • The message processor modules provide a Plug-like processing pipeline for server request dispatch.
  • ExMCP.Content.* modules normalize content, sanitize inputs, and validate schema-related data.
  • ACP mapper/protocol/session modules keep adapter-specific decoding separate from subprocess ports.

This structure keeps side effects at the edges: GenServers, Ports, HTTP requests, Plug connections, filesystem-backed session stores, and telemetry.

Resilience And Pipelines

ExMCP currently has three pipeline-style boundaries:

HTTP client connection handling is transport-owned today. If ExMCP later adds a public client middleware API, it should wrap request construction and transport send/receive at the ExMCP.Client boundary rather than inside HTTP-specific code, so stdio, HTTP, and BEAM-local can share the same cross-cutting behavior.

Module Map

lib/ex_mcp/
  acp/                 ACP protocol, client, native agent, adapters
  authorization/       OAuth 2.1 and auth provider flows
  client/              Client operations, handlers, state, connection setup
  content/             Content builders, validation, sanitization
  http_plug/           HTTP Plug functional core and SSE handling
  internal/            Private protocol, era/version, map, and security helpers
  message_processor/   Plug-like MCP request processing
  plugs/               Reusable Plug security/auth components
  protocol/            Public protocol utility modules
  reliability/         Retry, circuit breaker, health check supervisor
  server/              Handler behavior, DSL, transport startup
  transport/           Stdio, HTTP, BEAM-local, test transports

Testing Architecture

The test suite covers unit, integration, interop, conformance, security, and transport behavior. ExMCP.Transport.Test and transport: :beam keep local server/client tests fast without starting subprocesses or network listeners.

External conformance scripts live in scripts/ and should be run for each supported MCP spec version before release:

./scripts/conformance.sh              # published legacy/core harness
./scripts/conformance.sh all-versions # all negotiated legacy MCP versions
./scripts/conformance.sh modern       # gating 2026-07-28 complete suites
./scripts/conformance.sh draft-alpha  # non-gating future-draft exploration
mix mcp.sync_spec --version 2026-07-28 --force  # refresh local docs/mcp-specs

Protocol version alignment

Protocol eraRevisionsExMCP 1.0 RC support
MCP legacy2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25Enabled by :legacy_only and both preference modes
MCP modern2026-07-28 (latest stable)Implemented; enabled by :modern_only and both preference modes
ACPmajor 1protocolVersion: 1

Design Rules

  • Prefer ExMCP.Server.Handler plus ExMCP.Server.DSL for servers.
  • Select an explicit protocol mode in deployments and tests; never infer an era solely from a method name after a connection has pinned.
  • Keep compatibility fallback in ExMCP.Client.ConnectionManager and ExMCP.Client.EraProbe; application operations must not implement their own modern-to-legacy retry.
  • Use transport: :beam for local BEAM MCP, not a separate service dispatcher.
  • Put HTTP authorization, origin, and request-signing checks in Plug pipelines.
  • Put transport failure handling in client retry/reliability options.
  • Keep pure protocol transformations in functional modules and side effects in GenServer, Port, Plug, or filesystem boundaries.
  • Treat the 2.0 target architecture as future work; do not describe planned runtime ownership or scheduler behavior here until it is implemented.