ExMCP Troubleshooting Guide

Copy Markdown View Source

stdio

Unexpected end of JSON input

The stdio transport requires stdout to contain only newline-delimited JSON-RPC. Avoid IO.puts/1, normal Logger output, or noisy startup scripts on stdout.

Use stderr for diagnostics:

IO.puts(:stderr, "debug")

For scripts with Mix.install/2, configure logging before installing deps:

Application.put_env(:ex_mcp, :stdio_mode, true)
Application.put_env(:logger, :level, :emergency)

Mix.install([{:ex_mcp, "~> 1.0.0-rc.8"}], verbose: false)

Server hangs after starting

Start stdio servers with:

MyServer.start_link(transport: :stdio)

For clients, command must be a list:

ExMCP.Client.start_link(transport: :stdio, command: ["node", "server.js"])

HTTP

Connection refused

Check the URL and endpoint path. If the path is included in url, ExMCP uses that as the default endpoint:

ExMCP.Client.start_link(transport: :http, url: "http://localhost:4000/mcp")

Or provide it explicitly:

ExMCP.Client.start_link(
  transport: :http,
  url: "http://localhost:4000",
  endpoint: "/mcp"
)

CORS errors

For Phoenix/Plug servers, configure CORS in your Plug pipeline or pass cors_enabled: true to ExMCP.HttpPlug.

SSE stream does not start

First identify the negotiated protocol era:

  • On legacy MCP, confirm the standalone GET stream is enabled and the client uses use_sse: true.
  • On MCP 2026-07-28, use_sse does not control streaming. Ordinary requests and subscriptions/listen own their SSE response on the POST that created them. Check that the proxy preserves Content-Type: text/event-stream, does not buffer or transform the response, and has an idle timeout longer than the configured keepalive interval.

Increase stream_handshake_timeout for slow deployments. See the Streamable HTTP comparison for the full era-specific lifecycle.

Protocol modes and MCP 2026-07-28

server/discover fails or the client does not fall back

Set the intended compatibility policy explicitly:

ExMCP.Client.start_link(
  transport: :http,
  url: "https://example.com/mcp",
  protocol_mode: :prefer_modern
)

:prefer_modern falls back to legacy initialize only when a live peer gives positive evidence that it is legacy-compatible. A timeout, authentication failure, transport break, cached-modern failure, recognized modern error, or unsupported modern revision is surfaced instead of being silently downgraded. Use :prefer_legacy for a deliberate rollback, or reset_era_cache: true once after an operator-controlled endpoint upgrade. Strict :modern_only and :legacy_only modes never fall back.

Error -32022: unsupported protocol version

The request's modern _meta["io.modelcontextprotocol/protocolVersion"], the MCP-Protocol-Version HTTP header, and the server's enabled protocol mode must agree. Inspect the error's data.requested and data.supported values, then check both client and server protocol_mode settings. Do not use protocol_version: "2025-11-25" to select an era; that option is only the legacy revision preference.

Invalid request metadata

Every MCP 2026-07-28 request must include a _meta object with:

{
  "io.modelcontextprotocol/protocolVersion": "2026-07-28",
  "io.modelcontextprotocol/clientCapabilities": {}
}

io.modelcontextprotocol/clientInfo is optional, but when present it must contain non-empty name and version strings. ExMCP adds these fields for its own clients; this error usually indicates a custom peer, manually constructed JSON-RPC message, or middleware that rewrote params._meta.

Error -32020: HTTP header mismatch

Modern HTTP requests must carry exactly one MCP-Protocol-Version and Mcp-Method header matching the JSON-RPC body. tools/call, resources/read, and prompts/get also require a matching Mcp-Name. Annotated tool arguments may require Mcp-Param-* headers.

ExMCP derives and replaces these headers automatically. If the error occurs with an ExMCP client, inspect reverse-proxy behavior: duplicate headers must not be collapsed by choosing one value, and routing headers must not be cached, normalized to a different value, or injected by middleware.

Result is rejected for missing resultType, ttlMs, or cacheScope

Every modern result needs resultType: "complete" or resultType: "input_required". A complete cacheable result must also contain a non-negative integer ttlMs and cacheScope equal to "public" or "private". Non-complete results must not contain cache hints. Legacy result maps do not gain these fields merely because the transport is HTTP.

When the server uses ExMCP's normal Handler or DSL dispatch, return the usual {:ok, result, state} / ToolResult.* shape and let ExMCP.Server.ResultNormalizer add resultType plus conservative cache defaults (ttlMs: 0, cacheScope: "private"). Suspend an operation with ExMCP.Server.DSL.Result.input_required/2 or the documented {:input_required, ...} handler tuple. If a custom peer constructs raw wire results or bypasses ExMCP dispatch, it must add and validate the modern fields itself. See Modern result cache hints.

GET or DELETE returns 405

This is expected on a :modern_only MCP endpoint. MCP 2026-07-28 has no standalone GET stream or session DELETE. Open a subscriptions/listen POST for long-lived notifications and cancel by closing its response stream. GET, DELETE, Mcp-Session-Id, and Last-Event-ID are retained only for enabled legacy Streamable HTTP connections.

BEAM-Local

Client cannot connect

transport: :beam requires a live server PID:

{:ok, server} = MyServer.start_link(transport: :beam)  # DSL provides start_link; raw handlers use HandlerServer
Process.alive?(server)

{:ok, client} = ExMCP.Client.start_link(transport: :beam, server: server)

Do not use transport: :native; it was removed in the 1.0 API cleanup.

DSL

Tools do not appear

Use ExMCP.Server.Handler and ExMCP.Server.DSL together, and make sure the server starts through a supported transport:

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

  tool "ping", "Health check" do
    run fn _args, state ->
      {:ok, %{content: [%{type: "text", text: "pong"}]}, state}
    end
  end
end

Debugging

Enable debug logging for non-stdio transports:

Logger.configure(level: :debug)

Inspect local server state when using BEAM-local tests:

:sys.get_state(server)

Run focused tests:

mix test test/ex_mcp/client_beam_transport_test.exs
mix test test/ex_mcp/server/transport_test.exs