HTTP-транспорт
Помимо stdio, Neva поддерживает потоковый HTTP-транспорт для удалённых подключений к MCP-серверу.
Эта страница описывает HTTP-сервер по умолчанию, построенный на фреймворке Volga. Он включается флагами server-full или http-server-volga и не требует дополнительной настройки с вашей стороны.
Если вам нужно разместить MCP-эндпоинт на другом HTTP-стеке — axum, hyper, actix-web или произвольном адаптере, — см. раздел Свой HTTP-стек. Оба варианта используют одну и ту же конфигурацию with_http(...), JWT-аутентификацию и проверки ролей/прав, описанные ниже.
Транспорт не хранит состояние
В MCP 2026-07-28 транспорт работает только по схеме «запрос — ответ»:
- Нет
Mcp-Session-Idв протоколе и нетDELETEсессии. - Нет отдельного SSE-потока
GET— серверные уведомления едут на запросеsubscriptions/listen, который клиент открывает сам. - Каждый запрос несёт заголовок
MCP-Protocol-Version, а также обязательные ключи_metaс версией протокола и возможностями клиента. - Заголовки маршрутизации (
Mcp-Method,Mcp-Name,Mcp-Param-{name}) обязаны совпадать с телом запроса, иначе запрос отклоняется сHeaderMismatch(-32020) и HTTP400.
POST получает ответ text/event-stream в трёх случаях:
Какой POST | Что несёт поток |
|---|---|
В его _meta есть io.modelcontextprotocol/logLevel | уведомления журнала этого запроса, а следом — его ответ |
В его _meta есть progressToken | уведомления прогресса этого запроса, а следом — его ответ |
Это запрос subscriptions/listen | подтверждение, затем каждое уведомление, допускаемое фильтром, пока поток не завершится |
Все остальные POST получают один JSON-объект.
legacy-specВозвращается транспорт с сессиями: Mcp-Session-Id, DELETE сессии и
отдельный SSE-поток GET с воспроизведением по Last-Event-ID. См.
Легаси-спецификация.
Запуск нескольких экземпляров
Поскольку транспорт не хранит состояние, multi round-trip запрос может попасть на любой экземпляр — поэтому, как только экземпляров больше одного, два общих ресурса становятся обязательными:
App::new()
// Без этого повторы, попавшие на другой экземпляр, не расшифруют
// `requestState`. neva предупреждает об этом при старте.
.with_request_state_secret(std::env::var("MCP_STATE_SECRET").unwrap().as_bytes())
// Без этого повтор из-за потерянного ответа заново выполнит обработчик
// и продублирует `on_commit`. Хранилище по умолчанию — на процесс.
.with_request_state_store(my_redis_store)
.with_options(|opt| opt.with_default_http())
.run()
.await;
Что именно защищает секрет и как его ротировать — см. Обязательный минимум для развёртывания на нескольких экземплярах.
Флаг компонента http-server теперь не привязан к конкретному фреймворку и больше не тянет за собой Volga. Чтобы оставить HTTP-сервер по умолчанию на Volga, используйте http-server-volga (или пресет server-full, который по-прежнему сам его подключает). Если у вас было features = ["http-server"] и нужно прежнее поведение из версий до v0.3.3, переименуйте флаг в http-server-volga.
Базовая настройка
Для запуска сервера на потоковом HTTP используйте with_http() в параметрах:
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;
}
Это запустит HTTP-сервер на 127.0.0.1:3000 с конечной точкой /mcp по умолчанию.
Кастомная конечная точка
Путь конечной точки MCP можно изменить с помощью with_endpoint():
App::new()
.with_options(|opt| opt
.with_http(|http| http
.bind("127.0.0.1:3000")
.with_endpoint("/my-mcp")))
.run()
.await;
Конфигурация HTTP по умолчанию
Для быстрого старта используйте with_default_http(), который привязывается к 127.0.0.1:3000 с конечной точкой по умолчанию:
App::new()
.with_options(|opt| opt.with_default_http())
.run()
.await;
Защита от DNS-rebinding
Сервер на loopback доступен любой странице, которую откроет браузер:
достаточно направить evil.example.com на 127.0.0.1 — и браузер
подключится. Запрос при этом действительно локальный; выдаёт атаку имя, по
которому к серверу обратились. Поэтому neva проверяет Origin и Host и
отвечает 403 Forbidden ещё до чтения тела запроса.
По умолчанию ничего вызывать не нужно. При привязке к loopback сервер
принимает только loopback-имена — localhost, что угодно из 127.0.0.0/8,
[::1] — на любом порту. При привязке к чему-то другому он принимает всё,
потому что имена, по которым развёртывание правомерно доступно, отсюда
неизвестны: за прокси Host — это то, что перешлёт прокси.
Развёртывание, которое свои имена знает, объявляет их через
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;
Что означает запись в списке
| Запись | Чему соответствует Origin |
|---|---|
https://app.example.com | этой схеме, хосту и порту (отсутствующий порт означает порт по умолчанию для схемы) |
app.example.com | этому хосту на любой схеме и любом порту |
app.example.com:8443 | этому хосту на любой схеме, но только на этом порту |
Предпочитайте полный origin. Голый хост доверяет всему, что отдаётся под этим именем, включая то, что висит на другом порту, — доверие к приложению не должно означать доверия ко всему остальному на его хосте.
Host в любом случае сверяется по имени хоста со всеми записями: он
говорит, куда запрос пришёл, а не кто его отправил, схемы не несёт, а за
прокси его порт — дело прокси. Сравнение везде регистронезависимое, loopback
принимается всегда, а запрос без обоих заголовков не трогают — он не от
браузера, а без имени никакого rebinding не бывает.
Как отключить проверку
// Туннель терминирует обращённое к браузеру имя и пересылает запрос сюда.
let http = HttpServer::new("127.0.0.1:3000").allow_any_origin();
allow_any_origin()
имеет смысл только при привязке к loopback, где проверка включена по
умолчанию. Прибегайте к нему, когда имя уже проверяет что-то перед сервером,
а не чтобы заглушить 403, причину которого не прочитали: этот 403 —
и есть работающая защита.
Проверка живёт в ядре транспорта, а не в адаптере Volga, поэтому
свой HTTP-стек получает её же — и политика переживает
with_engine(...), поскольку это свойство развёртывания, а не фреймворка,
который его обслуживает.
TLS
Для включения HTTPS настройте TLS с помощью метода with_tls():
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 автоматически генерирует самоподписанный сертификат для локальной разработки.
В продакшене используйте собственный сертификат и файл ключа.
JWT-аутентификация
Neva поддерживает аутентификацию по токену Bearer через JWT для HTTP-транспорта.
Для включения используйте with_auth() внутри 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;
Параметры конфигурации аутентификации
| Метод | Описание |
|---|---|
set_decoding_key() | Секретный или публичный ключ для проверки подписи JWT |
with_aud() | Принимаемые значения audience токена |
with_iss() | Принимаемые значения issuer токена |
validate_exp() | Проверять ли срок действия токена (по умолчанию true) |
Управление доступом на основе ролей
После настройки аутентификации можно ограничить доступ к отдельным инструментам, запросам и ресурсам с помощью атрибутов roles и permissions:
/// Доступно всем
#[tool]
async fn public_tool(name: String) {
tracing::info!("Running public tool for {name}");
}
/// Только для пользователей с ролью "admin"
#[tool(roles = ["admin"])]
async fn admin_tool(name: String) {
tracing::info!("Running admin tool for {name}");
}
/// Только для пользователей с ролью "admin" и правом "read"
#[prompt(roles = ["admin"], permissions = ["read"])]
async fn restricted_prompt(topic: String) -> PromptMessage {
PromptMessage::user()
.with(format!("Restricted topic: {topic}"))
}
/// Только для пользователей с правом "read"
#[resource(uri = "res://restricted/{name}", permissions = ["read"])]
async fn restricted_resource(uri: Uri, name: String) -> (String, String) {
(uri.to_string(), name)
}
Роли и права извлекаются из claims JWT-токена. При несоответствии требованиям доступ отклоняется с ошибкой 403 Forbidden.
Блокирующий запуск
Для сценариев, где необходима синхронная точка входа (например, встраивание в неасинхронный контекст), вместо .run().await можно использовать run_blocking():
fn main() {
App::new()
.with_options(|opt| opt.with_default_http())
.run_blocking();
}
Тестирование с MCP Inspector
Для тестирования потокового HTTP-сервера через MCP Inspector сначала запустите сервер:
cargo run
Затем откройте Inspector и подключитесь к http://127.0.0.1:3000/mcp.