#
- [2026-07-28 specification](https://modelcontextprotocol.io/specification/2026-07-28)
- [2026-07-28 key changes](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
Read the [OAuth authorization guide](crate::guides::oauth) before exposing an MCP endpoint
outside a trusted network.
## Install the server transport
```toml
[dependencies]
tower-mcp = { version = "0.25", features = ["http"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
axum = "0.8"
```
Add `protocol-2026-07-28` when this deployment should accept the released
sessionless protocol. Compile-time availability and runtime selection are
separate; see the [protocol-version guide](crate::guides::protocol_versions).
## Standalone or mounted
`serve` is the shortest standalone path. It mounts the MCP endpoint at `/` and
the health check at `/health`:
```rust,no_run
use tower_mcp::{BoxError, HttpTransport, McpRouter};
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let mcp = McpRouter::new().server_info("my-server", "1.0.0");
HttpTransport::new(mcp).serve("127.0.0.1:3000").await?;
Ok(())
}
```
Bind to loopback for a local server. Binding to `0.0.0.0` makes the service
reachable from other hosts and should be paired with TLS termination,
authentication, and explicit origin/host policy.
For an existing axum application, mount the complete transport router at a
stable path:
```rust,no_run
use axum::{Router, routing::get};
use tower_mcp::{BoxError, HttpTransport, McpRouter};
async fn ready() -> &'static str { "ready" }
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let mcp_service = McpRouter::new().server_info("my-server", "1.0.0");
let mcp = HttpTransport::new(mcp_service).into_router_at("/mcp");
let app = Router::new()
.route("/readyz", get(ready))
.merge(mcp);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
```
The MCP endpoint is now `/mcp` and its built-in health route is
`/mcp/health`. Use `into_router_at_with_handle` when an admin endpoint needs a
`SessionHandle` for session count, inspection, or termination. The
[`axum_embedding` example](https://github.com/joshrotenberg/tower-mcp/blob/main/examples/axum_embedding.rs) is the canonical
shared-router pattern.
When OAuth is enabled, prefer `into_oauth_router_at` rather than nesting an
already-built OAuth router manually. It derives the protected-resource
metadata path and resource identifier from the mount path correctly.
## Origin and host validation
`HttpTransport` validates browser `Origin` headers by default:
- a request without `Origin` is accepted;
- a request with more than one `Origin` header is rejected;
- localhost origins (`localhost`, `127.0.0.1`, `::1`, any port) are always
accepted, whether or not `allowed_origins` is set -- it is a DNS-rebinding
guard against a locally bound server, not something a configured allowlist
replaces; `allowed_origins` only adds non-localhost origins on top of it.
The origin is parsed first, so `http://localhost:1@evil.com` (userinfo, real
host `evil.com`) is not localhost;
- a non-localhost origin is rejected unless it matches `allowed_origins`;
- an empty allowlist therefore rejects browser cross-origin access.
`allowed_origins` entries are parsed once, when the transport is built, into
`(scheme, host, port)` with the scheme's default port filled in, and compared
against the same normalized form of the incoming `Origin` header. So
`"https://example.com:443"`, `"https://Example.com"`, and
`"https://example.com/"` are all equivalent to `"https://example.com"`:
matching is on the parsed origin, not the exact string. An entry that isn't
`"*"` or a bare `scheme://host[:port]` origin (a path other than `/`, a
query, a fragment, userinfo, or a scheme other than `http`/`https`/`ws`/`wss`)
is logged at `warn` and ignored when `allowed_origins` is called, since it can
never match a real `Origin` header.
Host validation is also enabled. Localhost variants are accepted. For
compatibility, non-localhost hosts are accepted when `allowed_hosts` is empty;
set an explicit allowlist in production to turn it into a strict check.
```rust,no_run
use tower_mcp::{HttpTransport, McpRouter};
let transport = HttpTransport::new(McpRouter::new())
.allowed_origins(vec!["https://app.example.com".to_string()])
.allowed_hosts(vec!["mcp.example.com".to_string()]);
```
Entries in `allowed_hosts` may include a port. Configure the reverse proxy to
preserve the public `Host` or authority value that appears in the allowlist.
Avoid `"*"` origins for authenticated endpoints. Disabling either validation
is intended for controlled tests and unusual trusted-network topologies, not
as a production fix for a proxy misconfiguration.
`WebSocketTransport` applies the same `Origin` rules to the upgrade request,
with the same `allowed_origins` and `disable_origin_validation` builder
methods, and answers a rejected handshake with `403`. It also applies the same
`Host` rules (`allowed_hosts` and `disable_host_validation`, same defaults) and
answers a rejected `Host` with `400`. Browsers do not apply
CORS to WebSocket connections, so this check is the only thing stopping a web
page from connecting to a WebSocket server bound to localhost.
Origin validation is a security check, not a CORS response policy. Browser
clients may also need an axum/tower-http CORS layer on the returned router to
emit `Access-Control-Allow-*` headers. Allow the MCP methods and headers the
client actually uses; final-protocol HTTP includes `MCP-Protocol-Version`,
`Mcp-Method`, `Mcp-Name`, and possibly `Mcp-Param-*` headers.
## Stateful legacy and sessionless final traffic
One `HttpTransport` can serve both protocol eras on the same URL when
`protocol-2026-07-28` is compiled and enabled:
| Client traffic | tower-mcp path | Operational state |
|---|---|---|
| `initialize`, then `MCP-Session-Id` | legacy `2025-11-25` / `2025-06-18` / `2025-03-26` | session state, GET/POST SSE, optional event replay |
| `MCP-Protocol-Version: 2026-07-28`, required request metadata, no session ID | final `2026-07-28` | independent requests, `server/discover`, explicit `subscriptions/listen` streams |
Legacy sessions are optional by default for compatibility with clients that
do not retain the session header. Use `require_sessions()` when the application
depends on strict 2025-11-25 session semantics:
```rust,no_run
use std::time::Duration;
use tower_mcp::{HttpTransport, McpRouter};
let transport = HttpTransport::new(McpRouter::new())
.require_sessions()
.session_ttl(Duration::from_secs(30 * 60))
.max_sessions(10_000);
```
The default session TTL is 30 minutes and the default cleanup interval is one
minute. There is no default maximum; set one to bound per-process session
memory. The built-in session and event stores are in-memory.
A legacy session is also bound to the principal that initialized it: later
POST, GET, and DELETE requests naming the session must resolve to the same
principal, and a different one is answered exactly as an unknown session ID
is. By default the principal is the OAuth `sub` claim, so with no
authentication in front of the transport every request is anonymous and
nothing changes. Use `session_principal_resolver` (or
`session_principal_from_extension` with `bridge_extension`) when identity
comes from something other than OAuth claims. The principal is persisted in
the `SessionRecord`, so it is enforced after a restore on another node; a
record written before binding existed restores as anonymous, so with
authentication enabled those clients re-initialize once after an upgrade.
See the [OAuth guide](crate::guides::oauth) for an example.
With a shared session store, a DELETE that lands on a node that never held the
session removes the stored record, so the session cannot be restored on any
other node afterwards. Nodes that already hold the session in memory are not
told; they keep serving it until its TTL passes, and their next request writes
the record back. There is no cross-node invalidation.
Final `subscriptions/listen` streams have a separate, bounded policy because
they do not consume legacy session slots. By default, a transport admits up to
256 active final subscriptions. Each stream accepts at most 64 KiB of combined
request-ID/filter metadata and retains 64 notifications or 256 KiB of
serialized notification data.
Configure lower host-specific and per-principal limits where appropriate:
```rust,no_run
# #[cfg(feature = "stateless")]
# {
use tower_mcp::{HttpTransport, McpRouter, SubscriptionLimits};
let limits = SubscriptionLimits::default()
.max_active(128)
.max_active_per_principal(8)
.max_metadata_bytes(32 * 1024)
.max_buffered_messages(32)
.max_buffered_bytes(128 * 1024);
let transport = HttpTransport::new(McpRouter::new())
.subscription_limits(limits);
# let _ = transport;
# }
```
Admission beyond a configured stream limit returns a JSON-RPC error without
opening an SSE response. If a reader falls behind either per-stream buffer
budget, only that stream closes with an in-band terminal error. The client can
reconnect; for asynchronous tools, `tasks/get` remains the authoritative way
to recover current task state.
The method named `stateless(StatelessConfig)` controls a historical SEP-1442
compatibility path. It does **not** enable the released 2026-07-28 lifecycle.
For new deployments, enable `protocol-2026-07-28` and select versions with
`ProtocolSupport` instead.
## Resumption and disconnection
Legacy Streamable HTTP can attach event IDs to SSE messages. A reconnecting
client sends `Last-Event-ID`, and the configured `EventStore` supplies later
events. The default in-memory store can replay only while the same process and
buffer survive. Supply an external implementation for restart or cross-node
replay.
The 2026-07-28 protocol removed SSE event IDs, `Last-Event-ID`, and replay. A
broken final-protocol response loses that in-flight request; a client may
issue a new request with a new request ID only when application semantics make
that safe. A broken `subscriptions/listen` stream is opened again as a new
subscription.
Do not interpret an SSE disconnect as proof that a tool was cancelled or did
not run. Cancellation and idempotency are application concerns at this
boundary.
## Horizontal scaling
Choose a topology from the protocol behavior, not just from where metadata is
stored:
| Topology | Works well for | Limitation |
|---|---|---|
| One instance, in-memory stores | local and small deployments | sessions and replay buffers disappear on restart |
| Sticky routing by legacy session | legacy sessions and bidirectional POST streams | rebalancing or instance loss breaks live process-local state |
| Shared `SessionStore` + `EventStore` | restoring legacy metadata and replaying buffered events across nodes | does not migrate pending requests, service futures, notification channels, or an open SSE response |
| Sessionless final requests | ordinary 2026-07-28 calls across any healthy node | an active `subscriptions/listen` connection remains owned by one node and has no replay |
For legacy traffic, session affinity remains the safe default. A Layer 7 load
balancer can hash `MCP-Session-Id` after initialization. If you use shared
stores, keep associated POST streams and their client responses on the node
that owns the originating request; roots, sampling, and elicitation channels
are process-local.
`SessionStore` persists identity, capabilities, timestamps, negotiated
version, and the principal the session is bound to. `EventStore` persists resumable legacy notification events. Configure
both when failover needs both behaviors:
```rust,no_run
use std::sync::Arc;
use tower_mcp::{HttpTransport, McpRouter};
use tower_mcp::event_store::{EventStore, MemoryEventStore};
use tower_mcp::session_store::{MemorySessionStore, SessionStore};
let sessions: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::new());
let events: Arc<dyn EventStore> = Arc::new(MemoryEventStore::new());
let transport = HttpTransport::new(McpRouter::new())
.session_store(sessions)
.event_store(events);
```
The snippet shows the trait wiring; replace both memory implementations with
an external backend for a real multi-instance deployment. See
[`horizontal_scaling.rs`](https://github.com/joshrotenberg/tower-mcp/blob/main/examples/horizontal_scaling.rs),
[`session_store.rs`](https://github.com/joshrotenberg/tower-mcp/blob/main/examples/session_store.rs), and
[`event_store.rs`](https://github.com/joshrotenberg/tower-mcp/blob/main/examples/event_store.rs).
## Testing a custom store
`SessionStore` and `EventStore` carry rules a store that compiles can still
break: the session's principal has to come back unchanged, `save` replaces the
whole record, replay returns events strictly after the given ID and in order.
The `testing` feature ships a contract suite for each trait, plus one for
`TaskStore`. Each takes a constructor that returns an empty store, runs every
check against a fresh one, and panics with the rule it found broken:
```toml
[dev-dependencies]
tower-mcp = { version = "0.25", features = ["http", "testing"] }
```
```rust,ignore
use tower_mcp::testing::store_contracts::{event_store_contract, session_store_contract};
#[tokio::test]
async fn redis_session_store_honors_the_contract() {
// A fresh key prefix or database per call keeps the checks independent.
session_store_contract(|| RedisSessionStore::for_test()).await;
}
#[tokio::test]
async fn redis_event_store_honors_the_contract() {
event_store_contract(|| RedisEventStore::for_test()).await;
}
```
The suites check documented behavior only. They do not check the buffer
capacity of an event store (the trait defines none). Do not run them under
`tokio::time::pause`; the expiry checks in the task and session suites wait in
real time. See `tower_mcp::testing::store_contracts` and the task store's
[module documentation](crate::async_task) for the rules each suite covers.
## Reverse proxies and SSE
The proxy must stream response bytes promptly and keep long-lived responses
open. An nginx location for a path-mounted endpoint looks like:
```nginx
location /mcp {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "";
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
Also verify these behaviors in the chosen ingress or CDN:
- response buffering and compression do not batch SSE events;
- idle timeouts exceed the intended stream lifetime or keepalives arrive
frequently enough;
- request body and header limits permit MCP payloads and `Mcp-Param-*` values;
- `MCP-Protocol-Version`, `MCP-Session-Id`, `Last-Event-ID`, `Mcp-Method`,
`Mcp-Name`, `Authorization`, and `WWW-Authenticate` pass unchanged;
- 202 responses and streaming content types are not rewritten;
- graceful deployment drain gives in-flight POST responses time to finish.
Test through the public proxy URL. A direct-to-pod test will not detect
buffering, header stripping, public-host allowlist mistakes, or TLS/resource
identifier mismatches.
## Timeouts and size limits
The transport rejects POST bodies larger than 4 MiB by default. Change that
with `max_body_size`; axum's `DefaultBodyLimit` does not control the MCP
endpoint because tower-mcp reads the raw request body itself.
Place timeouts according to what should be cancelled:
1. Put a handler-specific timeout on `ToolBuilder`, `ResourceBuilder`, or
`PromptBuilder` when only that operation needs a deadline.
2. Use `HttpTransport::layer` for an MCP-wide request-processing policy.
3. Use an outer axum layer for HTTP concerns such as header parsing or a
short admission timeout.
4. Do not put a short generic response timeout around the entire axum router;
it will also terminate intended SSE and `subscriptions/listen` streams.
Always maintain an absolute operation deadline. Progress notifications are
evidence of activity, but should not permit an unbounded request. Configure
proxy and load-balancer idle timeouts separately from application deadlines.
## Middleware order and scope
tower-mcp has three relevant middleware boundaries:
| Boundary | API | Sees | Good uses |
|---|---|---|---|
| HTTP router | `transport.into_router[_at]().layer(...)` | MCP HTTP, health, and metadata routes | CORS response policy, request IDs, HTTP tracing, forwarded-header policy |
| MCP service | `HttpTransport::layer(...)` | parsed MCP requests | principal extensions, MCP metrics, global concurrency or timeout policy |
| One handler | `.layer(...)` on a tool/resource/prompt builder | only that operation | operation timeout, rate limit, circuit breaker, audit policy |
An outer axum layer runs before transport validation and parsing. An MCP layer
runs after the request has become a typed router request. A handler layer runs
only after routing selected that capability. Build authentication with
`into_oauth_router`/`into_oauth_router_at`; those helpers keep bearer validation
and operation-scope enforcement in the intended order.
When using `HttpTransport::from_service`, wrap the supplied service before
construction. `HttpTransport::layer` is intentionally unavailable because the
transport cannot reconstruct the opaque service stack.
## Health, shutdown, and observability
The built-in health route returns process liveness. It does not prove an
external authorization server, session store, event store, or downstream tool
dependency is ready. Add a separate application readiness route when those
dependencies should gate traffic.
For graceful shutdown, give the transport a shutdown future. Once it
resolves the listener stops accepting, requests already in flight are
answered, and `serve_with_shutdown` returns:
```rust,no_run
use std::time::Duration;
use tower_mcp::{BoxError, HttpTransport, McpRouter};
#[tokio::main]
async fn main() -> Result<(), BoxError> {
HttpTransport::new(McpRouter::new())
.drain_timeout(Duration::from_secs(10))
.serve_with_shutdown("0.0.0.0:3000", async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
Ok(())
}
```
`drain_timeout` bounds the wait for requests still in flight. It is
unbounded by default, and a client holding an SSE notification stream open
counts as in flight, so a server that has to stop within a deadline should
set it.
A Task-backed `tools/call` stops counting as an HTTP request after its Task
object has been returned, while a built-in live handler can continue running.
Use the router's live-execution handle when shutdown must also settle those
detached futures:
```rust,no_run
use std::time::Duration;
use tower_mcp::{BoxError, HttpTransport, McpRouter};
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let router = McpRouter::new();
let executions = router.live_task_execution_handle();
let shutdown_executions = executions.clone();
HttpTransport::new(router)
.drain_timeout(Duration::from_secs(10))
.serve_with_shutdown("0.0.0.0:3000", async move {
let _ = tokio::signal::ctrl_c().await;
// Shares the same registry as every transport-owned router clone.
shutdown_executions.close_admission();
})
.await?;
// This policy chooses cancellation after HTTP and subscription drain.
// Cancelling earlier, or allowing natural settlement, is also a valid
// application choice.
executions.cancel_all("server shutting down");
if tokio::time::timeout(Duration::from_secs(20), executions.drained())
.await
.is_err()
{
tracing::warn!("live task execution drain timed out");
}
Ok(())
}
```
The handle covers `live_task_handler` executions, including task preparation;
it does not treat every durable `TaskStore` record as in-process work. The
transport deliberately does not call `cancel_all` or choose a live-execution
deadline. Close admission before cancellation so an execution cannot arrive
after the cancellation pass, and keep timeout/escalation policy in the host.
Own the axum lifecycle instead when the MCP endpoint is one route among
several, or is mounted somewhere other than the root:
```rust,no_run
use tower_mcp::{BoxError, HttpTransport, McpRouter};
#[tokio::main]
async fn main() -> Result<(), BoxError> {
let app = HttpTransport::new(McpRouter::new()).into_router_at("/mcp");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
Ok(())
}
```
Use structured `tracing` output and propagate a request ID or OpenTelemetry
context at the HTTP boundary. Avoid logging authorization headers, access
tokens, tool secrets, or sensitive arguments. `into_router_with_handle` can
feed active-session metrics, but high-cardinality session IDs should not be
metric labels.
## Production checklist
- [ ] Mount a stable public endpoint and test the exact public URL.
- [ ] Terminate TLS and configure OAuth or another appropriate trust boundary.
- [ ] Keep Origin validation enabled and configure exact browser origins.
- [ ] Configure the public host allowlist and preserve `Host` through proxies.
- [ ] Select the intended runtime protocol versions explicitly.
- [ ] Decide whether legacy sessions are optional or required.
- [ ] Use affinity and/or shared stores according to the legacy features in use.
- [ ] Disable proxy buffering and set stream-aware idle timeouts.
- [ ] Bound bodies, sessions, handler execution, HTTP drain, and live Task drain time.
- [ ] Test JSON responses, POST SSE, legacy GET SSE/resumption if supported,
final `subscriptions/listen`, 202 notifications, and error bodies.
- [ ] Separate liveness from dependency-aware readiness.
- [ ] Redact secrets and confirm logs remain on stderr for stdio deployments.
## Runnable examples
```bash
# Standalone HTTP server
cargo run --example http_server --features http
# Mount under /mcp in an existing axum app
cargo run --example axum_embedding --features http
# Shared-store and cross-instance patterns
cargo run --example horizontal_scaling --features http
cargo run --example session_store --features http
cargo run --example event_store --features http
# Final sessionless server/client in one process
cargo run --example stateless_http_client \
--features "http,http-client,protocol-2026-07-28"
```
The feature-gated
[`tower_mcp::deployment`](https://docs.rs/tower-mcp/latest/tower_mcp/deployment/)
reference contains additional systemd, proxy, and capacity-planning notes.
"####]