HTTP Transport
In addition to stdio, Neva supports Streamable HTTP transport for remote MCP server connections.
This page covers the default HTTP server, built on the Volga framework. It is enabled by server-full or the http-server-volga feature flag and requires no extra wiring on your part.
If you need to host the MCP endpoint on a different HTTP stack — axum, hyper, actix-web, or any custom adapter — see Custom HTTP Stack. Both paths share the same with_http(...) configuration, JWT auth, and role/permission gates described below.
The Transport is Stateless
Under MCP 2026-07-28 the transport is request/response only:
- No
Mcp-Session-Idon the wire, and no sessionDELETE. - No standalone SSE
GETstream — server-initiated pushes ride asubscriptions/listenrequest the client opens itself. - Every request carries the
MCP-Protocol-Versionheader, plus mandatory_metakeys for the protocol version and the client's capabilities. - Routing headers (
Mcp-Method,Mcp-Name,Mcp-Param-{name}) must agree with the body, or the request is rejected withHeaderMismatch(-32020) and HTTP400.
A POST gets a text/event-stream reply in three cases:
The POST | The stream carries |
|---|---|
Its _meta carries io.modelcontextprotocol/logLevel | that request's log notifications, then its response |
Its _meta carries a progressToken | that request's progress notifications, then its response |
It is a subscriptions/listen request | the acknowledgment, then every notification the filter admits, until the stream ends |
Every other POST gets a single JSON object.
legacy-specThe session-bound transport comes back: Mcp-Session-Id, session DELETE,
and SSE GET streams with Last-Event-ID replay — a session hosts as many at
once as the client opens, each with its own cursor. See
Legacy spec → Concurrent SSE streams.
Running More Than One Instance
Because the transport is stateless, a multi round-trip request can land on any instance — so shared resources become mandatory as soon as you run more than one:
App::new()
// Without this, cross-instance retries cannot decrypt `requestState`.
// neva warns at startup if it is missing.
.with_request_state_secret(std::env::var("MCP_STATE_SECRET").unwrap().as_bytes())
// Without this, a lost-response retry re-runs the handler and
// double-fires `on_commit`. The default store is per-process.
.with_request_state_store(my_redis_store)
// Without this, a subscriber on one instance never hears about a
// mutation that happened on another.
.with_notification_bus(my_redis_bus)
.with_options(|opt| opt.with_default_http())
.run()
.await;
| Setting | Protects against |
|---|---|
with_request_state_secret | A cross-instance MRTR retry that cannot decrypt its requestState |
with_request_state_store | A lost-response retry re-running the handler and double-firing on_commit |
with_notification_bus | A subscription stream held on one instance missing what another instance produced |
Where several services share one with_request_state_secret, add
with_request_state_audience
so a state minted by one is not a state the others accept.
See Deployment must-do for multi-instance HTTP for what the secret protects and how to rotate it.
Basic Setup
To start a server on Streamable HTTP, use with_http() in your options:
use neva::prelude::*;
#[tokio::main]
async fn main() {
App::new()
.with_options(|opt| opt
.with_http(|http| http
.bind("127.0.0.1:3000")))
.run()
.await;
}
This starts an HTTP server on 127.0.0.1:3000 with the default /mcp endpoint.
Custom Endpoint
You can change the MCP endpoint path with with_endpoint():
App::new()
.with_options(|opt| opt
.with_http(|http| http
.bind("127.0.0.1:3000")
.with_endpoint("/my-mcp")))
.run()
.await;
Default HTTP Configuration
For a quick start, use with_default_http(), which binds to 127.0.0.1:3000 with the default endpoint:
App::new()
.with_options(|opt| opt.with_default_http())
.run()
.await;
DNS-Rebinding Protection
A server on loopback is reachable by any page the browser loads: point
evil.example.com at 127.0.0.1 and the browser will connect. The request
really is local — what gives the attack away is the name it was addressed
by. Neva therefore validates Origin and Host and answers 403 Forbidden
before reading the body.
The default needs no call. Bound to loopback, the server accepts only
loopback names — localhost, anything in 127.0.0.0/8, [::1] — on any
port. Bound to anything else it accepts everything, because the names a
deployment is legitimately reached by are not knowable from here: behind a
proxy the Host is whatever that proxy forwards.
Whether an address counts as loopback is decided the way std reads a bind
string, so an unbracketed IPv6 address works: bind("::1:3000") listens on
[::1]:3000 and gets the loopback policy. Bracket it anyway — [::1]:3000
says what it means without relying on the last-colon rule.
An Origin carrying userinfo is matched on its real host, not on the name in
front of the @: https://app.example.com:8443@evil.com is evil.com.
A deployment that does know its names states them with
with_allowed_origins():
let http = HttpServer::new("0.0.0.0:3000")
.with_allowed_origins(["https://mcp.example.com", "https://app.example.com"]);
App::new()
.with_options(|opt| opt.set_http(http))
.run()
.await;
What an entry means
| Entry | Matches an Origin of |
|---|---|
https://app.example.com | that scheme, host and port (a missing port means the scheme's default) |
app.example.com | that host on any scheme and any port |
app.example.com:8443 | that host on any scheme, narrowed to that port |
Prefer the full origin. A bare host trusts everything served under that name, including whatever sits on another port — trusting an application should not mean trusting the rest of its host.
Host is matched by hostname against every entry either way: it says where
the request landed rather than who sent it, carries no scheme, and behind a
proxy its port is the proxy's business. Matching is case-insensitive
throughout, loopback is always accepted, and a request carrying neither
header is left alone — it is not from a browser, and there is no rebinding
without a name.
Turning the gate off
// A tunnel terminates the browser-facing name and forwards here.
let http = HttpServer::new("127.0.0.1:3000").allow_any_origin();
allow_any_origin()
is only meaningful on a loopback bind, where the gate is on by default.
Reach for it when something in front of the server already validates the
name — not to quiet a 403 whose cause has not been read, because that
403 is the protection working.
The gate lives in the transport core, not in the Volga adapter, so a
custom HTTP stack gets the same validation — and the policy
survives with_engine(...), since it is a property of the deployment rather
than of the framework serving it.
TLS
To enable HTTPS, configure TLS using the with_tls() method:
let http = HttpServer::new("localhost:7878")
.with_tls(|tls| tls
.with_dev_cert(DevCertMode::Auto));
App::new()
.with_options(|opt| opt.set_http(http))
.run()
.await;
DevCertMode::Auto automatically generates a self-signed certificate for local development.
In production, provide your own certificate and key files instead.
JWT Authentication
Neva supports bearer token authentication via JWT on the HTTP transport.
To enable it, use with_auth() inside with_http():
let secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set");
App::new()
.with_options(|opt| opt
.with_http(|http| http
.with_auth(|auth| auth
.validate_exp(false)
.with_aud(["my-service"])
.with_iss(["my-issuer"])
.set_decoding_key(secret.as_bytes()))))
.run()
.await;
Auth Configuration Options
| Method | Description |
|---|---|
set_decoding_key() | Secret or public key used to verify JWT signatures |
with_aud() | Accepted token audience values |
with_iss() | Accepted token issuer values |
validate_exp() | Whether to validate the token expiration (default true) |
Role-Based Access Control
Once authentication is configured, you can restrict access to individual tools, prompts, and resources using roles and permissions attributes:
/// Accessible to everyone
#[tool]
async fn public_tool(name: String) {
tracing::info!("Running public tool for {name}");
}
/// Only accessible to users with the "admin" role
#[tool(roles = ["admin"])]
async fn admin_tool(name: String) {
tracing::info!("Running admin tool for {name}");
}
/// Only accessible to users with the "admin" role and "read" permission
#[prompt(roles = ["admin"], permissions = ["read"])]
async fn restricted_prompt(topic: String) -> PromptMessage {
PromptMessage::user()
.with(format!("Restricted topic: {topic}"))
}
/// Only accessible to users with the "read" permission
#[resource(uri = "res://restricted/{name}", permissions = ["read"])]
async fn restricted_resource(uri: Uri, name: String) -> (String, String) {
(uri.to_string(), name)
}
Roles and permissions are extracted from JWT token claims. Access is denied with a 403 Forbidden if the token does not satisfy the declared requirements.
set_decoding_key is for a deployment that mints its own JWTs. To validate
tokens issued by an OAuth 2.1 / OIDC provider — against its JWKS, with the
RFC 9728 metadata document and the 401 challenge that lets clients discover
it — see OAuth 2.1. The role and permission gates above are
identical either way.
Blocking Runner
For use cases where you need a synchronous entry point (e.g., embedding in a non-async context), you can use run_blocking() instead of .run().await:
fn main() {
App::new()
.with_options(|opt| opt.with_default_http())
.run_blocking();
}
Stopping the Server
Both runners stop on SIGINT / SIGTERM with no configuration. To stop one
from your own code — a test, or neva embedded in a service that owns its
lifecycle — see Graceful Shutdown.
Testing with MCP Inspector
To test a Streamable HTTP server using the MCP Inspector, start your server first:
cargo run
Then open the Inspector and connect to http://127.0.0.1:3000/mcp.