# ExMCP Transport Guide

ExMCP supports stdio, Streamable HTTP, BEAM-local, and test transports. The
deprecated MCP 2024-11-05 HTTP+SSE transport is an explicit compatibility
option, not a new-server default.

## Overview

| Transport | Identifier | Best For |
|-----------|------------|----------|
| stdio | `:stdio` | Official MCP subprocess transport |
| Streamable HTTP | `:http` | Remote servers and Phoenix apps |
| BEAM-local | `:beam` | Local Elixir client/server pairs |
| Test | `:test` | In-memory tests |

## stdio

The stdio transport spawns an external MCP server process and exchanges
newline-delimited JSON-RPC.

```elixir
{:ok, client} =
  ExMCP.Client.start_link(
    transport: :stdio,
    command: ["node", "server.js"],
    cd: "/path/to/project",
    env: [{"NODE_ENV", "production"}]
  )
```

Supported options:

- `:command` - executable plus arguments as a list.
- `:cd` - subprocess working directory.
- `:env` - environment variables as `{"KEY", "VALUE"}` tuples.
- `:timeout` - client operation timeout.

## Streamable HTTP

The HTTP transport supports two wire shapes on one MCP POST endpoint. A
protocol mode determines how the client establishes the era; it is not a
choice between separate `:http` transport modules.

| Behavior | Legacy Streamable HTTP (2025-03-26–2025-11-25) | Modern Streamable HTTP (2026-07-28) |
|---|---|---|
| Establishment | `initialize`; server may mint `Mcp-Session-Id` | `server/discover`; no protocol session |
| Client messages | POST; session header after initialization | A fresh stateless POST for every message |
| Ordinary response | JSON or request-owned SSE | JSON or request-owned SSE |
| Independent notifications | Optional standalone GET SSE stream | `subscriptions/listen` POST response stream |
| Server needs client input | JSON-RPC request on an SSE stream | `input_required` result followed by a new client POST |
| Resume/termination | `Last-Event-ID`; DELETE session | Not resumable; close the owning response stream |

The client's `use_sse` option controls the standalone GET stream used by
legacy Streamable HTTP. ExMCP disables it, clears any session ID, and stops
sending `Last-Event-ID` after a connection settles modern. Modern request and
subscription streams use SSE on the owning POST response automatically and do
not require `use_sse: true`.

```elixir
{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    protocol_mode: :prefer_modern,
    use_sse: true,
    headers: [{"Authorization", "Bearer #{token}"}],
    request_timeout: 30_000,
    stream_handshake_timeout: 15_000,
    stream_idle_timeout: 60_000
  )
```

Supported client options include:

- `:url` - base URL or full MCP endpoint URL.
- `:endpoint` - endpoint path when it is not included in `url`.
- `:headers` - additional request headers.
- `:protocol_mode` - `:legacy_only`, `:prefer_legacy`, `:prefer_modern`, or
  `:modern_only`; see the [Configuration Guide](CONFIGURATION.md#protocol-eras-and-modes).
- `:use_sse` - enable the legacy Streamable HTTP GET stream, defaults to `true`.
- `:session_id` - resume an existing streamable HTTP session.
- `:protocol_version` - requested legacy revision, or a modern probe revision
  when the selected mode enables it.
- `:timeout` - connect timeout.
- `:request_timeout` - single request timeout.
- `:stream_handshake_timeout` - wait for SSE stream startup.
- `:stream_idle_timeout` - allowed SSE idle time.
- `:max_retry_delay` - cap for SSE reconnect delay.
- `:security` - client-side security validation configuration.
- `:auth` / `:auth_provider` - OAuth/auth provider integration.

For OAuth client registration, configure one explicit strategy:

```elixir
auth: %{
  client_registration:
    {:pre_registered, "client-id", {:env, "MCP_CLIENT_SECRET"}},
  credential_issuer: "https://auth.example.com",
  redirect_port: 8080
}

# Or a self-hosted Client ID Metadata Document:
auth: %{
  client_registration:
    {:cimd, "https://client.example/oauth/metadata.json"}
}
```

`:auto` uses pre-existing compatibility keys first, then a configured
`client_metadata_url` when the authorization server advertises CIMD, then
deprecated DCR only when `registration_endpoint` is advertised. DCR requires
an explicit `application_type: :native | :web` and a stable `redirect_port`;
ExMCP never invents a CIMD URL or guesses the application type. A missing
strategy returns an actionable registration error.

Modern pre-registered credentials must include `credential_issuer`, which is
compared exactly with discovered AS metadata. To retain DCR credentials and
tokens safely across flows, configure an adapter implementing
`ExMCP.Authorization.CredentialStore`; registrations are issuer + client-ID
bound and tokens use the complete authorization partition. See the
[Configuration Guide](CONFIGURATION.md#issuer-bound-credential-persistence).

### Modern POST shape

Every modern request is a new POST whose body contains one JSON-RPC request.
The client advertises both response types:

```http
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: weather

{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{...}}
```

ExMCP derives the routing headers from the validated body. It strips custom
values for reserved MCP headers before sending a modern request, so callers
cannot create a header/body disagreement.

| Header | When sent | Meaning |
|---|---|---|
| `MCP-Protocol-Version` | Every modern request | Mirrors modern `_meta.protocolVersion` |
| `Mcp-Method` | Every modern request | Mirrors the JSON-RPC method |
| `Mcp-Name` | `tools/call`, `resources/read`, and `prompts/get` | Mirrors the addressed tool, resource, or prompt |
| `Mcp-Param-*` | Annotated `tools/call` inputs | Mirrors schema-selected routing parameters |

Header names are case-insensitive on the wire. `Mcp-Param-*` values may
contain sensitive routing data; redact them in reverse-proxy, load-balancer,
and APM logs as well as application logs.

The server returns one of these shapes:

- `application/json` with the final JSON-RPC response;
- `text/event-stream` with request-related notifications and then the final
  response; or
- `202 Accepted` with no body for an accepted notification POST.

A modern result always has `resultType: "complete"` or
`resultType: "input_required"`. For `input_required`, the client satisfies the
embedded elicitation, sampling, or roots requests and sends the original
operation again as a new POST with `inputResponses` and the opaque
`requestState`. ExMCP never turns those inputs into independent server-to-client
JSON-RPC requests on the HTTP stream.

Long-lived notifications use a `subscriptions/listen` request. Its POST
response stays open as SSE, begins with the subscription acknowledgement, and
then carries only the notification categories selected by that request.
Closing a modern request/subscription response stream is the cancellation
signal; there is no session DELETE and no resumable `Last-Event-ID` cursor.

A `:modern_only` HTTP mount returns `405 Method Not Allowed` for GET or DELETE
on the MCP endpoint, ignores legacy session headers, and never exposes the
deprecated HTTP+SSE endpoints. A dual-era mount must retain whatever legacy
GET/DELETE behavior its enabled legacy clients need, so use request metadata
and the settled client era—not the mere presence of HTTP—to reason about the
wire shape.

### Safe era fallback

With `:prefer_modern`, ExMCP sends a bounded `server/discover` probe first. It
falls back to `initialize` only when the response is recognized as evidence of
a legacy peer and the transport is still usable. A recognized modern error,
unsupported modern revision, timeout, authentication failure, or broken
transport is surfaced instead of being silently downgraded. `:modern_only`
never falls back.

Successful modern observations are pinned by endpoint and relevant transport
configuration. Legacy observations expire (five minutes by default) so an
upgraded endpoint is eventually probed again. Pass `reset_era_cache: true` for
an intentional re-probe after an operator-controlled deployment change.

### Phoenix/Plug Server

```elixir
scope "/mcp" do
  pipe_through [:api, :mcp_auth]

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

Put HTTP concerns in Plug pipelines before `ExMCP.HttpPlug`: authentication,
request signing, rate limiting, CORS/origin decisions, and DNS rebinding checks.

### Deprecated MCP 2024-11-05 HTTP+SSE

Existing deployments can retain the old two-endpoint transport throughout
ExMCP 1.x by opting in:

```elixir
forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  legacy_http_sse: true
```

The GET endpoint defaults to `/sse`; its first event is `endpoint`, containing
the POST URI (default `/message`) and session ID. Configure those paths with
`:legacy_http_sse_path` and `:legacy_http_sse_post_path`. The rc.5
`:sse_enabled` option remains a deprecated alias until ExMCP 2.0. New servers
should use Streamable HTTP and leave this option off. Selecting
`:prefer_legacy` or `:prefer_modern` does not enable this transport;
`:modern_only` disables it even if the compatibility flag is present.

## BEAM-Local

The BEAM-local transport carries MCP-shaped maps/lists as Elixir terms between
local processes. It does not JSON encode/decode in the transport, but it still
uses the MCP lifecycle, request IDs, capabilities, and handler callbacks. Its
protocol mode selects legacy `initialize` or modern `server/discover` plus
per-request context, exactly as stdio does.

```elixir
{:ok, server} =
  MyServer.start_link(
    transport: :beam,
    protocol_mode: :prefer_modern
  )

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

Supported options:

- server side: `transport: :beam` on a DSL/handler server, with optional
  `protocol_mode`.
- client side: `transport: :beam`, `server: pid`, optional `protocol_mode` and
  `timeout`.

**Tip:** For a fast local verification of these BEAM + DSL + Client patterns (no re-installs), run `mix examples.getting_started` from the project root after `mix compile`.

BEAM-local does not provide service discovery or distributed registry behavior.
If you need a pool or registry of server processes, keep that in your
application supervision layer and pass the selected server PID to the client.

## Test Transport

Use `:test` for in-memory tests where both endpoints are in the same process
tree:

```elixir
{:ok, server} =
  ExMCP.Server.HandlerServer.start_link(
    transport: :test,
    handler: MyServer
  )

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

## Reliability

Client retries:

```elixir
ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  retry_policy: [max_attempts: 3, initial_delay: 100, max_delay: 2_000]
)
```

Transport wrapper:

```elixir
ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  reliability: [
    circuit_breaker: [failure_threshold: 5, reset_timeout: 30_000],
    health_check: [check_interval: 60_000]
  ]
)
```

## Telemetry

Transports emit connection and message telemetry. BEAM-local events use the
generic transport event names with `metadata.transport == :beam`:

- `[:ex_mcp, :transport, :connection, :opened]`
- `[:ex_mcp, :transport, :message, :sent]`
- `[:ex_mcp, :transport, :message, :received]`

## Selection Guide

- Use `:stdio` for official subprocess MCP servers.
- Use `:http` for network boundaries and Phoenix integrations.
- Use `:beam` for trusted local Elixir processes.
- Use `:test` for tests.
