Architecture Document: MCP Elixir SDK

Copy Markdown View Source

Document Info

  • Project: MCP Elixir SDK (Hex package mcp_elixir_sdk)
  • Version: 1.0.2
  • Date: 2026-02-09
  • Status: Phase 7 Complete — 100% Conformance (Tier 1)
  • Protocol: MCP 2025-11-25

1. Protocol Overview

MCP uses JSON-RPC 2.0 over stateful connections. The protocol has three phases:

1. Initialization    Client sends initialize request
                     Server responds with capabilities
                     Client sends initialized notification

2. Operation         Bidirectional JSON-RPC messages
                     Based on negotiated capabilities

3. Shutdown          Transport-level disconnection

Roles

Host Application
  |
  +-- Client 1  Server A (tools: weather, stocks)
  +-- Client 2  Server B (resources: files, git)
  +-- Client 3  Server C (prompts: code review)
  • Host: Application containing one or more clients
  • Client: Maintains 1:1 session with a server. Provides sampling, roots, elicitation to server.
  • Server: Provides tools, resources, prompts to client. May request sampling/elicitation from client.

2. Module Map

lib/mcp/
  # === Core Protocol (Phase 1 - COMPLETE) ===
  protocol.ex                        # JSON-RPC 2.0 encoding/decoding
  protocol/
    error.ex                         # MCP error codes + JSON-RPC errors
    methods.ex                       # Method name constants
    types/
      tool.ex                        # Tool struct
      tool_annotations.ex            # ToolAnnotations struct
      resource.ex                    # Resource struct
      resource_template.ex           # ResourceTemplate struct
      resource_contents.ex           # ResourceContents struct
      prompt.ex                      # Prompt struct
      prompt_argument.ex             # PromptArgument struct
      prompt_message.ex              # PromptMessage struct
      sampling_message.ex            # SamplingMessage struct
      model_preferences.ex           # ModelPreferences struct
      model_hint.ex                  # ModelHint struct
      root.ex                        # Root struct
      implementation.ex              # Implementation struct (client/server info)
      annotations.ex                 # Content Annotations struct
      icon.ex                        # Icon struct
      content.ex                     # Content type dispatcher
      content/
        text_content.ex              # TextContent struct
        image_content.ex             # ImageContent struct
        audio_content.ex             # AudioContent struct
        embedded_resource.ex         # EmbeddedResource struct
        resource_link.ex             # ResourceLink struct
    capabilities/
      server_capabilities.ex         # ServerCapabilities struct
      client_capabilities.ex         # ClientCapabilities struct
      tool_capabilities.ex           # ToolCapabilities struct
      resource_capabilities.ex       # ResourceCapabilities struct
      prompt_capabilities.ex         # PromptCapabilities struct
      logging_capabilities.ex        # LoggingCapabilities struct
      completion_capabilities.ex     # CompletionCapabilities struct
      sampling_capabilities.ex       # SamplingCapabilities struct
      root_capabilities.ex           # RootCapabilities struct
      elicitation_capabilities.ex    # ElicitationCapabilities struct
    messages/
      request.ex                     # JSON-RPC Request struct
      response.ex                    # JSON-RPC Response struct
      notification.ex                # JSON-RPC Notification struct
      initialize.ex                  # Initialize Params + Result
      ping.ex                        # Ping Params
      tools.ex                       # Tools ListParams/ListResult/CallParams/CallResult
      resources.ex                   # Resources List/Read/Subscribe/Templates types
      prompts.ex                     # Prompts List/Get types
      sampling.ex                    # Sampling CreateMessage Params/Result
      roots.ex                       # Roots List Params/Result
      elicitation.ex                 # Elicitation Params/Result
      logging.ex                     # Logging SetLevel/Message types
      completion.ex                  # Completion Params/Result
      notifications.ex               # Progress/Cancelled/ResourceUpdated params

  # === Transport Layer (Phase 2 + Phase 5 - COMPLETE) ===
  transport.ex                       # Transport behaviour (start_link, send_message, close)
  transport/
    stdio.ex                         # Port-based stdin/stdout transport (client + server modes)
    sse.ex                           # SSE encoding/decoding utilities
    streamable_http/
      client.ex                      # HTTP POST + SSE client transport (Req)
      server.ex                      # Server-side transport GenServer (bridges Plug ↔ MCP.Server)
      plug.ex                        # Plug endpoint handling POST/GET/DELETE HTTP methods
      pre_started.ex                 # Transport adapter for reusing existing transport pid

  # === Client (Phase 3 - COMPLETE) ===
  client.ex                          # High-level client API (GenServer)

  # === Server (Phase 4 + Phase 7 - COMPLETE) ===
  server.ex                          # High-level server API (GenServer, async tool support)
  server/
    handler.ex                       # Behaviour for tool/resource/prompt handlers
    tool_context.ex                  # Context for async tool handlers (Phase 7)

3. Transport Architecture

Transport Behaviour

@callback start_link(opts :: keyword()) :: GenServer.on_start()
@callback send_message(pid :: pid(), message :: map()) :: :ok | {:error, term()}
@callback close(pid :: pid()) :: :ok

Transports run as GenServer processes. The owner receives messages via:

  • {:mcp_message, decoded_map} — incoming JSON-RPC message
  • {:mcp_transport_closed, reason} — transport closed/disconnected

Stdio Transport

MCP.Client (GenServer)
  |
  +-- Port (stdin/stdout to subprocess)
  |     Write: JSON + newline to stdin
  |     Read: Newline-delimited JSON from stdout
  |     Stderr: Logged (not protocol messages)
  |
  +-- MCP Server Process (child)
  • Client launches server as subprocess via Port.open/2
  • Messages are newline-delimited JSON-RPC (no embedded newlines)
  • Server's stderr is captured/logged but not parsed as protocol

Streamable HTTP Transport (Phase 5 - COMPLETE)

Client Side:                          Server Side:
MCP.Client (GenServer)               StreamableHTTP.Plug (Plug endpoint)
  |                                     |
  +-- StreamableHTTP.Client             +-- POST  route_post  deliver_message
  |   (GenServer, Transport)            |      StreamableHTTP.Server (GenServer)
  |   Sends HTTP POST (Req)             |      MCP.Server (handler callbacks)
  |   Parses JSON or SSE response       |      response routed back to caller
  |                                     |
  |   Headers:                          +-- GET  SSE stream (server-initiated)
  |   Content-Type: application/json    |
  |   Accept: application/json,         +-- DELETE  terminate session
  |           text/event-stream         |
  |   MCP-Session-Id: <sid>             +-- ETS session registry
  |   MCP-Protocol-Version: 2025-11-25 |     {session_id  transport_pid}
  |                                     |
  +-- On close: HTTP DELETE             +-- PreStarted adapter
                                              (reuses transport pid for MCP.Server)

Architecture overview:

  • Client: StreamableHTTP.Client GenServer implements Transport behaviour. Sends JSON-RPC via Req.post/2, parses application/json or SSE responses. Extracts MCP-Session-Id from initialize response, includes in all subsequent requests. On close, sends HTTP DELETE to terminate session.

  • Server: Three-module design:

    • StreamableHTTP.Plug — Plug handling POST/GET/DELETE HTTP methods. Creates sessions (transport + MCP.Server pairs) on initialize. Routes requests via ETS session registry.
    • StreamableHTTP.Server — Transport GenServer bridging Plug ↔ MCP.Server. Stores pending response callers so responses can be routed back to the correct HTTP connection.
    • StreamableHTTP.PreStarted — Adapter that lets MCP.Server reuse an already-started transport process (since the Plug starts the transport before the MCP.Server).
    • handler_opts seam (1.1.0): the Plug threads request-scoped identity from the authenticated Plug pipeline into Handler.init/1 — a static keyword list or a per-session (Plug.Conn.t() -> keyword()) factory evaluated once at initialize. Backward-compatible (absent handler_opts = prior behaviour). See handler-opts-identity-seam-spec.md.
  • SSE: MCP.Transport.SSE provides encoding/decoding utilities:

    • encode_event/1, encode_message/2 for SSE event creation
    • decode_event/1 for parsing SSE event text
    • new_parser/0, feed/2 for incremental/chunked SSE stream parsing
  • Session management: Server generates UUID session IDs, stores in ETS. Client extracts from MCP-Session-Id response header. Protocol version validated via MCP-Protocol-Version.

  • Handshake ordering: a session's MCP.Server stays :waiting until it receives notifications/initialized; clients MUST drive initialize → notifications/initialized → tools/call (requests before :ready are rejected with "Server not initialized"). This is the most common consumer stumble. MCP.Client.connect/1 does this automatically; raw HTTP clients must send notifications/initialized themselves.

  • Dependencies: req ~> 0.5 (HTTP client), plug ~> 1.16 (HTTP framework), bandit ~> 1.5 (HTTP server) — all optional, only needed for Streamable HTTP


4. Client Architecture

MCP.Client (GenServer)
  |
  +-- state:
  |     transport_module / transport_pid  the transport process
  |     server_capabilities: ServerCapabilities.t()
  |     server_info: Implementation.t()
  |     client_info / client_capabilities  sent during initialization
  |     pending_requests: %{id => {from, timeout_ref}}
  |     next_id: integer (incrementing)
  |     status: :disconnected | :initializing | :ready | :closed
  |     notification_handler: pid | (method, params -> any)
  |     request_handlers: %{method => callback_fn}
  |
  +-- Public API:
  |     start_link/1          create GenServer + start transport
  |     connect/1-2           initialize handshake
  |     list_tools/2          tools/list
  |     call_tool/3-4         tools/call
  |     list_resources/2      resources/list
  |     read_resource/2-3     resources/read
  |     list_resource_templates/2  resources/templates/list
  |     subscribe_resource/2-3     resources/subscribe
  |     unsubscribe_resource/2-3   resources/unsubscribe
  |     list_prompts/2        prompts/list
  |     get_prompt/3-4        prompts/get
  |     ping/1-2              ping (works pre-init)
  |     close/1               shutdown
  |     list_all_tools/2      paginated tools/list
  |     list_all_resources/2  paginated resources/list
  |     list_all_prompts/2    paginated prompts/list
  |
  +-- Incoming (from server):
        notifications  dispatch to notification_handler (pid or function)
        requests (sampling, elicitation)  dispatch to request_handlers map

Request/Response Matching

Client assigns incrementing integer IDs to outgoing requests. Each pending request stores {from, timeout_ref} in a map. When a response arrives via {:mcp_message, decoded}, Protocol.decode_message/1 classifies it and the matching ID resolves the pending GenServer.call/3 via GenServer.reply/2. Timeouts use Process.send_after/3.

Server-Initiated Requests

MCP servers can send requests to clients (sampling, roots, elicitation). The client dispatches these to callback functions provided at start:

{:ok, client} = MCP.Client.start_link(
  transport: {MCP.Transport.Stdio, command: "server", args: []},
  client_info: %{name: "my_app", version: "1.0.0"},
  request_handlers: %{
    "sampling/createMessage" => fn _method, params -> {:ok, result} end,
    "roots/list" => fn _method, _params -> {:ok, %{"roots" => []}} end
  },
  notification_handler: self()  # or fn method, params -> ... end
)

5. Server Architecture

MCP.Server (GenServer)
  |
  +-- state:
  |     handler_module / handler_state  user's Handler behaviour implementation
  |     transport_module / transport_pid  the transport process
  |     client_capabilities: ClientCapabilities.t()
  |     client_info: Implementation.t()
  |     server_info / capabilities / instructions  declared at startup
  |     status: :waiting | :ready | :closed
  |     pending_requests: %{id => {from, timeout_ref}}
  |     next_id: integer (incrementing, for server-initiated requests)
  |     log_level: current log level set by client
  |
  +-- Public API:
  |     start_link/1           create GenServer + start transport + init handler
  |     close/1                shutdown
  |     transport/1, status/1  accessors
  |     client_capabilities/1, client_info/1  from initialization
  |
  +-- Notifications (server  client):
  |     notify_tools_changed/1      notifications/tools/list_changed
  |     notify_resources_changed/1  notifications/resources/list_changed
  |     notify_resource_updated/2   notifications/resources/updated (with uri)
  |     notify_prompts_changed/1    notifications/prompts/list_changed
  |     log/3-4                     notifications/message (respects log level)
  |     send_progress/3-4           notifications/progress
  |
  +-- Server-initiated requests (server  client):
  |     request_sampling/2-3        sampling/createMessage
  |     request_roots/1-2           roots/list
  |     request_elicitation/2-3     elicitation/create
  |
  +-- Incoming (from client):
  |     initialize  respond with capabilities, store client info
  |     notifications/initialized  transition to :ready
  |     ping  empty response (works pre-init)
  |     tools/list, tools/call  dispatch to handler
  |     resources/list, resources/read  dispatch to handler
  |     resources/subscribe, resources/unsubscribe  dispatch to handler
  |     resources/templates/list  dispatch to handler
  |     prompts/list, prompts/get  dispatch to handler
  |     completion/complete  dispatch to handler
  |     logging/setLevel  dispatch to handler + update log_level
  |     unknown method  -32601 error

Handler Behaviour

MCP.Server.Handler defines optional callbacks for all server features. The server auto-detects capabilities by inspecting which callbacks the handler module exports via __info__(:functions).

@callback init(opts) :: {:ok, state}
@callback handle_list_tools(cursor, state) :: {:ok, tools, next_cursor, state}
@callback handle_call_tool(name, arguments, state) :: {:ok, content, state} | {:error, code, msg, state}
@callback handle_call_tool(name, arguments, context, state) :: {:ok, content, state} | {:error, code, msg, state}  # async (Phase 7)
@callback handle_list_resources(cursor, state) :: {:ok, resources, next_cursor, state}
@callback handle_read_resource(uri, state) :: {:ok, contents, state} | {:error, code, msg, state}
@callback handle_subscribe(uri, state) :: {:ok, state} | {:error, code, msg, state}
@callback handle_unsubscribe(uri, state) :: {:ok, state} | {:error, code, msg, state}
@callback handle_list_resource_templates(cursor, state) :: {:ok, templates, next_cursor, state}
@callback handle_list_prompts(cursor, state) :: {:ok, prompts, next_cursor, state}
@callback handle_get_prompt(name, arguments, state) :: {:ok, result, state} | {:error, code, msg, state}
@callback handle_complete(ref, argument, state) :: {:ok, completion, state}
@callback handle_set_log_level(level, state) :: {:ok, state}

Request Routing

Routing is inline in the Server GenServer via pattern-matched function clauses on %Request{method: "tools/list"} etc. No separate Router module needed — Elixir's pattern matching makes this clean and Credo-friendly.

Async Tool Execution (Phase 7)

Tools that implement handle_call_tool/4 (with ToolContext) execute asynchronously in a spawned Task. This allows tools to send intermediate messages during execution:

Client                      Plug                    Server                  Handler Task
  |                           |                       |                       |
  +-- POST tools/call ------->|                       |                       |
  |                           +-- register_stream --->|                       |
  |                           +-- deliver_async ----->|                       |
  |                           |                       +-- Task.async -------->|
  |                           |                       |                       |
  |                           |                       |<-- context_notify ----|  (log)
  |                     {sse_event} <-- send_message -|                       |
  |<-- SSE: log notification -|                       |                       |
  |                           |                       |<-- context_request ---|  (sampling)
  |                     {sse_event} <-- send_message -|                       |
  |<-- SSE: sampling request -|                       |                       |
  |                           |                       |                       |
  +-- POST sampling response->|                       |                       |
  |                           +-- deliver_message --->|                       |
  |                           |                       +-- reply to request -->|
  |                           |                       |                       |
  |                           |                       |<-- Task completes ----|
  |                     {sse_done} <-- send_message --|                       |
  |<-- SSE: tool result ------| (stream closed)       |                       |

Key components:

  • MCP.Server.ToolContext — context struct with server_pid, request_id, meta
  • handle_call_tool/4 — async callback detected via __info__(:functions)
  • HTTPTransport send_message/3 — opts [related_request_id: id] routes to correct SSE stream
  • Plug stream_loop/1 — chunked SSE receive loop for {:sse_event, data} and {:sse_done, data}

Capability Auto-Detection

The server inspects handler_module.__info__(:functions) to detect which callbacks are implemented, then builds %ServerCapabilities{} accordingly:

  • handle_list_tools/2 → tools capability (with listChanged)
  • handle_list_resources/2 → resources capability (with listChanged)
  • handle_subscribe/2 → resources.subscribe capability
  • handle_list_prompts/2 → prompts capability (with listChanged)
  • handle_set_log_level/2 → logging capability
  • handle_complete/3 → completions capability

6. JSON-RPC 2.0 Message Types

Request

{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}

Response (success)

{"jsonrpc": "2.0", "id": 1, "result": {"tools": [...]}}

Response (error)

{"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Unknown tool"}}

Notification (no response expected)

{"jsonrpc": "2.0", "method": "notifications/tools/list_changed"}

Standard Error Codes

CodeMeaning
-32700Parse error
-32600Invalid request
-32601Method not found
-32602Invalid params
-32603Internal error
-32002Resource not found
-32042URL elicitation required
-1User rejected sampling

7. Capability Negotiation

During initialization, both sides declare what they support:

Server Capabilities

%ServerCapabilities{
  tools: %{listChanged: true},
  resources: %{subscribe: true, listChanged: true},
  prompts: %{listChanged: true},
  logging: %{},
  completions: %{}
}

Client Capabilities

%ClientCapabilities{
  roots: %{listChanged: true},
  sampling: %{tools: %{}},
  elicitation: %{form: %{}, url: %{}}
}

Both sides MUST respect declared capabilities throughout the session.


8. Content Types

Tool results, prompts, and resources can contain multiple content types:

TypeFieldsUsage
TextContenttype: "text", textMost common
ImageContenttype: "image", data (base64), mimeTypeVisual content
AudioContenttype: "audio", data (base64), mimeTypeAudio content
ResourceContenttype: "resource", resource (uri, text/blob)Embedded resources
ResourceLinktype: "resource_link", uri, name, mimeTypeLinks to resources

9. Elixir/OTP Design Patterns

MCP ConceptElixir Implementation
Client sessionGenServer per connection
Server instanceGenServer per connection
Stdio transportPort (Erlang port for subprocess)
SSE streamReq + stream processing / Plug.Conn chunked
Request/response matchingMap of %{id => from} in GenServer state
Notificationssend/2 to registered handler processes
Tool registrationMap in GenServer state
JSON-RPC framingJason.encode!/1 + Jason.decode!/1
Session lifecycleGenServer init/handle_call/terminate
Concurrent clientsSupervisor with dynamic children
PaginationCursor-based, lazy with Stream

10. Testing Strategy

Unit Tests

  • Protocol encoding/decoding (JSON-RPC messages)
  • Type serialization/deserialization
  • Capability negotiation logic
  • Transport message framing (stdio, HTTP)
  • Client API (with mock transport)
  • Server API (with mock transport)

Integration Tests

  • Client ↔ Server over stdio (in-process)
  • Client ↔ Server over HTTP (localhost)
  • Full lifecycle: init → operations → shutdown

Conformance Tests

  • Official MCP conformance suite via npx @modelcontextprotocol/conformance
  • Server mode: conformance framework connects to our server
  • Client mode: conformance framework tests our client
  • Expected failures baseline file for incremental compliance
  • GitHub Actions integration for CI

11. Dependencies

Required

DepPurpose
jasonJSON encoding/decoding
elixir_uuidID generation

Optional

DepPurposeWhen Needed
reqHTTP clientStreamable HTTP client transport
plugHTTP server frameworkStreamable HTTP server transport
banditHTTP serverStreamable HTTP server transport
castoreTLS certificatesHTTPS connections

Dev/Test

DepPurpose
dialyxirType checking
credoStatic analysis
ex_docDocumentation