FastestMCP 0.2 targets MCP 2025-11-25 over streamable HTTP and stdio.
Both transports accept JSON-RPC 2.0 only and feed the same operation pipeline.
Streamable HTTP
The configured MCP endpoint is /mcp by default. It is the only built-in HTTP
route:
POST /mcpcarries exactly one JSON-RPC request, notification, or responseGET /mcpopens the session event streamDELETE /mcpterminates the session
Every POST must use JSON media (application/json; parameters and casing are
accepted). Its Accept header must allow both application/json and
text/event-stream, even when the server ultimately chooses a JSON response.
Media parsing is structural: type/subtype matching is case-insensitive and
honors parameters, wildcards, ordering, and quality values. An unsupported
request media type receives 415 Unsupported Media Type; a request with no
acceptable response representation receives 406 Not Acceptable. JSON-RPC
batch arrays are rejected. Accepted notifications and client responses receive
202 Accepted with an empty body. A deployment that sets
enable_get_streaming: false returns 405 Method Not Allowed for GET.
There are no built-in /health, /mcp/tools, /mcp/resources/read, or other
method-specific routes. Add application health endpoints or custom routes in
the surrounding Plug/Phoenix router, outside the MCP endpoint.
Session lifecycle
An HTTP client follows this sequence:
- POST
initializewithoutMCP-Session-Id. - Read the server-issued
MCP-Session-Idresponse header. - POST
notifications/initializedwith that session id andMCP-Protocol-Version: 2025-11-25. - Send later POST, GET, and DELETE requests with both headers.
Requests before notifications/initialized, client-chosen session ids,
unsupported subsequent MCP-Protocol-Version headers, and unknown or
terminated sessions are rejected. An initialize request that proposes another
version receives a successful result selecting 2025-11-25. The connected
FastestMCP.Client performs this lifecycle automatically.
Sessions provide protocol identity, task ownership, subscriptions, and server
notifications. GET /mcp uses event-stream framing inside streamable HTTP; it
is not the removed standalone SSE transport.
Streamed POST dispatch runs beneath the server runtime's Task.Supervisor and
has a configurable stream_request_timeout_ms: (60 seconds by default). A
network disconnect does not imply MCP cancellation: detached work continues
under supervision. notifications/cancelled stops cancellable ordinary work;
task-augmented work remains controlled by tasks/cancel, and initialize is
never cancelled.
Request-local handler state
FastestMCP no longer exposes a zero-session HTTP mode. The former
stateless_http: and stateless: options fail at startup because they cannot
represent initialize ordering, callback correlation, or session ownership.
Use request-local application state when every handler call must begin with an empty state map:
FastestMCP.http_app(MyApp.MCPServer,
state_scope: :request,
allowed_hosts: :localhost
)Request-scoped state:
- resets
Context.get_state/3andContext.set_state/4values for each operation - still mints and requires a normal
MCP-Session-Id - retains negotiated protocol, client information, capabilities, callbacks, task ownership, subscriptions, GET streams, and DELETE termination
Use the default state_scope: :session when handler values should persist
between requests in the same MCP session.
Bidirectional streams and replay
Server-originated roots, sampling, elicitation, ping, task, progress, logging,
and cancellation messages all pass through the session coordinator. A POST may
switch to SSE when its handler originates a callback; a correlated client
response sent in a later POST receives 202 Accepted and resolves the waiter.
Each message is assigned to exactly one origin-affine POST or live GET sink.
GET and POST SSE events are persisted before transmission. Each event uses an
opaque, authenticated id that binds its session, original logical stream, and
event position without exposing the session id. Reconnect by GET with
Last-Event-ID to replay retained events once, in order, from only that stream.
Malformed or foreign-session ids receive 400 Bad Request; an authentic id
from the current session whose retained event or stream was evicted receives
410 Gone.
Defaults retain 256 events and 4 MiB per stream for five minutes, with a 64 MiB
runtime-wide replay limit. Configure those oldest-first bounds with
sse_replay_max_events:, sse_replay_max_stream_bytes:,
sse_replay_max_total_bytes:, and sse_replay_ttl_ms:. The built-in replay
store is process-local. A host that requires replay across process or node
failure owns durable persistence and routing of a resumed session to that
store.
The connected client accepts only JSON and SSE response media, retains SSE
id and retry fields, resumes either a POST-originated or GET-originated
stream with GET plus Last-Event-ID, clamps retry delays, and suppresses
duplicate delivery. It stops retrying after the session is closed. A network
disconnect alone is not a cancellation; use Request.cancel/2 or
tasks/cancel as appropriate.
A session GET that receives 404 while carrying an established session id
uses the same one-shot recovery as a POST: initialize again without the stale
id, send notifications/initialized, and open a new GET without the old event
id. A 404 from that replacement session is terminal so a deleted server-side
session cannot cause an initialization loop.
Plug Embedding
FastestMCP.http_app/2 returns a Plug-compatible app:
children = [
{Bandit,
plug: FastestMCP.http_app(MyApp.MCPServer, allowed_hosts: :localhost),
port: 4100}
]You can also run the transport child spec directly:
children = [
FastestMCP.streamable_http_child_spec(MyApp.MCPServer,
port: 4100,
stream_request_timeout_ms: 60_000,
allowed_hosts: :localhost
)
]Phoenix forwarding uses the same transport module. Configure the actual public host names explicitly:
forward "/mcp", FastestMCP.Transport.HTTPApp,
server_name: MyApp.MCPServer,
path: "/mcp",
allowed_hosts: ["mcp.example.com"]When the route sits behind your Plug or Phoenix auth pipeline, select assigns
for auth with auth_assigns::
pipeline :mcp do
plug :fetch_session
plug MyAppWeb.UserAuth, :fetch_current_user
end
scope "/" do
pipe_through :mcp
forward "/mcp", FastestMCP.Transport.HTTPApp,
server_name: MyApp.MCPServer,
path: "/mcp",
allowed_hosts: ["mcp.example.com"],
auth_assigns: [:current_user]
endSelected assigns are copied into auth input under "assigns" and are not added
to normal handler request metadata.
Host and listener safety
DNS-rebinding protection defaults to allowed_hosts: :localhost, which accepts
localhost, 127.0.0.1, and loopback IPv6. For a deployed service, pass a
non-empty list of concrete host names:
FastestMCP.streamable_http_child_spec(MyApp.MCPServer,
port: 4100,
bandit_options: [ip: {0, 0, 0, 0}],
allowed_hosts: ["mcp.example.com", "internal-mcp.example.net"]
)The transport validates both Host and, when present, Origin. A non-loopback
listener refuses to start without a concrete host list. allowed_hosts: :any
and unsafe_allow_any_host: are no longer valid. Origin validation accepts one
serialized HTTP(S) origin with no user information, path, query, or fragment;
malformed, opaque, combined, repeated, and unlisted origins receive 403.
Stdio
The stdio transport is available for local tooling and process-owned workflows:
FastestMCP.stdio_dispatch(MyApp.MCPServer, request)Use FastestMCP.Transport.Stdio for a long-lived stdio entrypoint. Pass the
server definition to the transport so Logger and group-leader isolation are in
place before lifespans and the rest of the runtime start:
server =
FastestMCP.server("local-mcp")
|> FastestMCP.add_tool("echo", fn arguments, _context -> arguments end)
FastestMCP.Transport.Stdio.serve(server)The transport owns that server until stdin closes. serve/4 also accepts the
name of an already-running server for embedding, but output emitted before the
transport takes control is necessarily the host launcher's responsibility.
Each line is one JSON-RPC 2.0 message; batches and native non-JSON-RPC maps are rejected.
Each stdio connection has one runtime-owned session and must complete
initialize followed by notifications/initialized before other requests.
A concurrent reader continues accepting callback responses and notifications
while supervised handlers run, and one serialized writer owns stdout. This
gives stdio the same roots, sampling, elicitation, peer-task, progress, logging,
ping, and cancellation behavior as HTTP. Application logs and child-process
diagnostics stay on stderr.
The transport gives handler and callback workers an stderr-backed group leader
so ordinary IO.puts/1, Logger output, startup messages, and malformed-input
diagnostics cannot contaminate the wire. Startup fails with an actionable error
if an active stdout Logger handler cannot be isolated safely. The original
stdout device is retained only by the serialized protocol writer, and each MCP
message is exactly one UTF-8 line with no interleaving.
Native code that writes directly to operating-system file descriptor 1 bypasses BEAM group leaders and Logger routing. Preventing or redirecting that output is the host application's responsibility.
Migrating from 0.1
- Point all MCP HTTP traffic at the single configured endpoint, normally
/mcp; move health checks and application routes to your own router. - Remove JSON-RPC batching and send each message separately.
- Stop supplying a session id on
initialize; retain the response header and sendnotifications/initializedbefore normal requests. - Send
MCP-Protocol-Version: 2025-11-25after initialization. - Remove
stateless_http:andstateless:. Usestate_scope: :requestto reset application state while retaining a normal MCP session. - Replace
allowed_hosts: :anyandunsafe_allow_any_host:with concrete hosts. - Authenticate initialize, notifications, client responses, GET, POST, and DELETE consistently; a session id is not an authentication credential.
Why This Shape
FastestMCP keeps transport parsing strict and the execution layer shared. HTTP and stdio therefore agree on lifecycle, auth, middleware, provider, and task semantics without retaining parallel legacy APIs.