Перейти к основному содержимому

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) и HTTP 400.

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;

Что именно защищает секрет и как его ротировать — см. Обязательный минимум для развёртывания на нескольких экземплярах.

Ломающее изменение в v0.3.3

Флаг компонента 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 — и есть работающая защита.

Работает с любым HTTP-движком

Проверка живёт в ядре транспорта, а не в адаптере 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.

Обучение на примерах