use crate::{
auth::Claims,
error::{Error, ErrorCode},
types::{Message, RequestId, Response},
};
use bytes::Bytes;
use futures_util::{Stream, StreamExt, future::Either, stream};
use http::{HeaderMap, HeaderValue};
use std::pin::Pin;
use std::sync::Arc;
use tokio_stream::wrappers::ReceiverStream;
use super::{
context::HttpContext,
engine::HttpEngine,
types::{HttpRequest, HttpResponse, StreamResponse},
};
pub(crate) const MCP_SESSION_ID: &str = "Mcp-Session-Id";
pub async fn dispatch_post<E: HttpEngine>(
req: E::Request,
ctx: &HttpContext,
) -> Result<StreamResponse<impl Stream<Item = E::SseEvent> + Send + 'static>, Error> {
let neutral = E::adapt_request(req).await?;
#[cfg(not(feature = "legacy-spec"))]
{
Ok(handle_post_streaming::<E>(neutral, ctx).await)
}
#[cfg(feature = "legacy-spec")]
{
let resp = handle_post(neutral, ctx).await;
Ok(StreamResponse::<stream::Empty<E::SseEvent>>::Complete(resp))
}
}
pub async fn dispatch_delete<E: HttpEngine>(
req: E::Request,
ctx: &HttpContext,
) -> Result<E::Response, Error> {
let neutral = E::adapt_request(req).await?;
let resp = handle_delete(neutral, ctx).await;
Ok(E::adapt_response(resp))
}
pub async fn dispatch_get_sse<E: HttpEngine>(
req: E::Request,
ctx: &HttpContext,
) -> Result<StreamResponse<impl Stream<Item = E::SseEvent> + Send + 'static>, Error> {
let neutral = E::adapt_request(req).await?;
Ok(handle_get_sse::<E>(neutral, ctx).await)
}
pub async fn handle_post(req: HttpRequest, ctx: &HttpContext) -> HttpResponse {
match prepare_post(req, ctx).await {
PostPrep::Reply(resp) => resp,
PostPrep::Dispatch { id, msg } => {
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel::<Message>();
ctx.pending.insert(msg.full_id(), resp_tx);
if ctx.inbound_tx.send(Ok(msg)).await.is_err() {
return status_response(http::StatusCode::INTERNAL_SERVER_ERROR, id);
}
match resp_rx.await {
Ok(resp) => build_json_response(dispatched_status(&resp), id, &resp),
Err(_) => status_response(http::StatusCode::INTERNAL_SERVER_ERROR, id),
}
}
}
}
enum PostPrep {
Reply(HttpResponse),
Dispatch { id: uuid::Uuid, msg: Message },
}
async fn prepare_post(req: HttpRequest, ctx: &HttpContext) -> PostPrep {
let mut headers = req.headers().clone();
let id = get_or_create_mcp_session(&headers);
if let Some(err) = ctx.origin_policy.rejection(&headers) {
return PostPrep::Reply(build_json_response(
http::StatusCode::FORBIDDEN,
id,
&Message::Response(Response::error(RequestId::Null, err)),
));
}
#[cfg(not(feature = "legacy-spec"))]
let version_err;
#[cfg(not(feature = "legacy-spec"))]
{
let header = headers
.get(crate::transport::http::MCP_PROTOCOL_VERSION)
.and_then(|v| v.to_str().ok());
version_err = match header {
None => Some(Error::new(
ErrorCode::HeaderMismatch,
"Missing or malformed MCP-Protocol-Version header",
)),
Some(v) if v != crate::LATEST_PROTOCOL_VERSION => Some(
Error::new(
ErrorCode::UnsupportedProtocolVersion,
format!("Unsupported MCP protocol version: {v}"),
)
.with_data(serde_json::json!({
"supported": [crate::LATEST_PROTOCOL_VERSION],
"requested": v,
})),
),
Some(_) => None,
};
}
let claims = req.extensions().get::<Arc<dyn Claims>>().cloned();
let body = req.into_body();
let msg = match parse_message(&body) {
Ok(msg) => msg,
Err(code) => {
#[cfg(not(feature = "legacy-spec"))]
if let Some(err) = version_err {
return PostPrep::Reply(build_json_response(
http::StatusCode::BAD_REQUEST,
id,
&Message::Response(Response::error(RequestId::Null, err)),
));
}
let resp = Response::error(RequestId::Null, Error::from(code));
return PostPrep::Reply(build_json_response(
http::StatusCode::OK,
id,
&Message::Response(resp),
));
}
};
#[cfg(not(feature = "legacy-spec"))]
if let Some(err) = version_err {
return PostPrep::Reply(build_json_response(
http::StatusCode::BAD_REQUEST,
id,
&reject_post(&msg, err),
));
}
#[cfg(not(feature = "legacy-spec"))]
{
let header_version = headers
.get(crate::transport::http::MCP_PROTOCOL_VERSION)
.and_then(|v| v.to_str().ok());
let invalid = match &msg {
Message::Request(r) => request_meta_error(r, header_version).is_some(),
Message::Batch(batch) => batch.iter().any(|env| match env {
crate::types::MessageEnvelope::Request(r) => {
request_meta_error(r, header_version).is_some()
}
_ => false,
}),
_ => false,
};
if invalid {
let reply = reject_post_each(&msg, |r| {
request_meta_error(r, header_version).unwrap_or_else(|| {
Error::new(
ErrorCode::InvalidRequest,
"Not processed: another request in this batch was rejected",
)
})
});
if let Some(reply) = reply {
return PostPrep::Reply(build_json_response(
http::StatusCode::BAD_REQUEST,
id,
&reply,
));
}
}
}
#[cfg(not(feature = "legacy-spec"))]
{
let invalid = match &msg {
Message::Request(r) => routing_header_error(r, &headers)
.map(|err| Message::Response(Response::error(r.id(), err))),
Message::Batch(_) => (headers.contains_key(crate::transport::http::MCP_METHOD)
|| headers.contains_key(crate::transport::http::MCP_NAME))
.then(|| {
reject_post(
&msg,
Error::new(
ErrorCode::HeaderMismatch,
"Mcp-Method / Mcp-Name cannot describe a batch and must be omitted",
),
)
}),
Message::Notification(n) => headers
.get(crate::transport::http::MCP_METHOD)
.and_then(|v| v.to_str().ok())
.filter(|stated| *stated != n.method.as_str())
.map(|stated| {
Message::Response(Response::error(
RequestId::Null,
Error::new(
ErrorCode::HeaderMismatch,
format!(
"Header mismatch: Mcp-Method header value {stated:?} \
does not match body value {:?}",
n.method
),
),
))
}),
_ => None,
};
if let Some(reply) = invalid {
return PostPrep::Reply(build_json_response(
http::StatusCode::BAD_REQUEST,
id,
&reply,
));
}
}
#[cfg(all(not(feature = "legacy-spec"), feature = "tracing"))]
if let Message::Request(ref r) = msg
&& let Some(meta) = r
.params
.as_ref()
.and_then(|p| p.get("_meta"))
.and_then(|m| m.as_object())
{
if let Some(tp) = meta.get("traceparent").and_then(|v| v.as_str()) {
tracing::Span::current().record("traceparent", tp);
}
if let Some(ts) = meta.get("tracestate").and_then(|v| v.as_str()) {
tracing::Span::current().record("tracestate", ts);
}
if let Some(bg) = meta.get("baggage").and_then(|v| v.as_str()) {
tracing::Span::current().record("baggage", bg);
}
}
#[cfg(feature = "legacy-spec")]
let is_init = matches!(msg, Message::Request(ref r) if r.method == crate::commands::INIT);
#[cfg(feature = "legacy-spec")]
if is_init {
ctx.sse_registry.pre_register(id);
}
#[cfg(feature = "legacy-spec")]
if !is_init && headers.contains_key(MCP_SESSION_ID) && !ctx.sse_registry.is_live(&id) {
let reply = reject_post_each(&msg, |_| {
Error::new(ErrorCode::InvalidRequest, "Session not found")
});
let mut builder = http::Response::builder().status(http::StatusCode::NOT_FOUND);
let body = match reply {
Some(reply) => {
builder = builder.header(http::header::CONTENT_TYPE, "application/json");
Bytes::from(serde_json::to_vec(&reply).unwrap_or_default())
}
None => Bytes::new(),
};
return PostPrep::Reply(builder.body(body).unwrap_or_default());
}
if matches!(msg, Message::Notification(_)) {
let msg = msg.set_session_id(id);
let _ = ctx.inbound_tx.send(Ok(msg)).await;
return PostPrep::Reply(status_response(http::StatusCode::ACCEPTED, id));
}
if let Message::Batch(ref batch) = msg
&& !batch.has_requests()
&& !batch.has_error_responses()
{
let msg = msg.set_session_id(id);
if ctx.inbound_tx.send(Ok(msg)).await.is_err() {
return PostPrep::Reply(status_response(http::StatusCode::INTERNAL_SERVER_ERROR, id));
}
return PostPrep::Reply(status_response(http::StatusCode::ACCEPTED, id));
}
headers.remove(http::header::AUTHORIZATION);
let mut msg = msg.set_session_id(id).set_headers(headers);
if let Some(c) = claims {
msg = msg.set_claims(c);
}
PostPrep::Dispatch { id, msg }
}
#[cfg(not(feature = "legacy-spec"))]
async fn handle_post_streaming<E: HttpEngine>(
req: HttpRequest,
ctx: &HttpContext,
) -> StreamResponse<impl Stream<Item = E::SseEvent> + Send + 'static> {
match prepare_post(req, ctx).await {
PostPrep::Reply(resp) => StreamResponse::Complete(resp),
PostPrep::Dispatch { id, msg } => {
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel::<Message>();
let full_id = msg.full_id();
ctx.pending.insert(full_id.clone(), resp_tx);
if !opts_into_notifications(&msg) {
if ctx.inbound_tx.send(Ok(msg)).await.is_err() {
ctx.pending.remove(&full_id);
return StreamResponse::Complete(status_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
id,
));
}
return match resp_rx.await {
Ok(resp) => StreamResponse::Complete(build_json_response(
dispatched_status(&resp),
id,
&resp,
)),
Err(_) => StreamResponse::Complete(status_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
id,
)),
};
}
let hold_for_ack = is_subscription_stream(&msg);
let notif_rx = crate::types::notification::sink::register(
id,
ctx.sse_log_queue_capacity,
hold_for_ack,
)
.await;
if ctx.inbound_tx.send(Ok(msg)).await.is_err() {
crate::types::notification::sink::unregister(&id);
ctx.pending.remove(&full_id);
return StreamResponse::Complete(status_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
id,
));
}
let stream = post_notification_stream(
id,
full_id,
ctx.pending.clone(),
notif_rx,
resp_rx,
hold_for_ack,
ctx.sse_log_queue_capacity,
)
.map(|msg| E::ephemeral_event(&msg));
StreamResponse::Stream {
headers: HeaderMap::new(),
stream,
}
}
}
}
#[cfg(not(feature = "legacy-spec"))]
fn opts_into_notifications(msg: &Message) -> bool {
match msg {
Message::Request(r) => request_opts_in(r),
Message::Batch(batch) => batch.iter().any(
|env| matches!(env, crate::types::MessageEnvelope::Request(r) if request_opts_in(r)),
),
_ => false,
}
}
#[cfg(not(feature = "legacy-spec"))]
fn is_subscription_stream(msg: &Message) -> bool {
fn is_listen(req: &crate::types::Request) -> bool {
req.method == crate::types::subscription::commands::LISTEN
}
match msg {
Message::Request(r) => is_listen(r),
Message::Batch(batch) => batch
.iter()
.any(|env| matches!(env, crate::types::MessageEnvelope::Request(r) if is_listen(r))),
_ => false,
}
}
#[cfg(not(feature = "legacy-spec"))]
fn request_opts_in(req: &crate::types::Request) -> bool {
if req.method == crate::types::subscription::commands::LISTEN {
return true;
}
req.params
.as_ref()
.and_then(|p| p.get("_meta"))
.and_then(|m| m.as_object())
.is_some_and(|meta| {
meta.contains_key("io.modelcontextprotocol/logLevel")
|| meta.contains_key("progressToken")
})
}
#[cfg(not(feature = "legacy-spec"))]
fn post_notification_stream(
id: uuid::Uuid,
full_id: RequestId,
pending: super::context::RequestMap,
notif_rx: tokio::sync::mpsc::Receiver<Message>,
resp_rx: tokio::sync::oneshot::Receiver<Message>,
hold_for_ack: bool,
hold_limit: usize,
) -> impl Stream<Item = Message> + Send {
struct Cleanup {
id: uuid::Uuid,
full_id: RequestId,
pending: super::context::RequestMap,
}
impl Drop for Cleanup {
fn drop(&mut self) {
crate::types::notification::sink::unregister(&self.id);
self.pending.remove(&self.full_id);
}
}
struct State {
notif_rx: tokio::sync::mpsc::Receiver<Message>,
resp_rx: Option<tokio::sync::oneshot::Receiver<Message>>,
response: Option<Message>,
notifs_open: bool,
_cleanup: Cleanup,
awaiting_ack: bool,
held: std::collections::VecDeque<Message>,
hold_limit: usize,
out: std::collections::VecDeque<Message>,
}
fn is_ack(msg: &Message) -> bool {
matches!(msg, Message::Notification(n)
if n.method == crate::types::subscription::commands::ACKNOWLEDGED)
}
enum Step {
Notification(Message),
NotificationsClosed,
Response(Option<Message>),
}
let state = State {
notif_rx,
resp_rx: Some(resp_rx),
response: None,
notifs_open: true,
_cleanup: Cleanup {
id,
full_id,
pending,
},
awaiting_ack: hold_for_ack,
held: std::collections::VecDeque::new(),
hold_limit,
out: std::collections::VecDeque::new(),
};
stream::unfold(state, |mut state| async move {
if let Some(msg) = state.out.pop_front() {
return Some((msg, state));
}
while state.notifs_open {
let step = {
let State {
notif_rx, resp_rx, ..
} = &mut state;
match resp_rx.as_mut() {
Some(rx) => tokio::select! {
biased;
n = notif_rx.recv() => match n {
Some(n) => Step::Notification(n),
None => Step::NotificationsClosed,
},
r = rx => Step::Response(r.ok()),
},
None => match notif_rx.recv().await {
Some(n) => Step::Notification(n),
None => Step::NotificationsClosed,
},
}
};
match step {
Step::Notification(n) if state.awaiting_ack => {
if is_ack(&n) {
state.awaiting_ack = false;
state.out.append(&mut state.held);
return Some((n, state));
}
if state.held.len() < state.hold_limit {
state.held.push_back(n);
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
logger = "neva",
"dropped a notification queued before the subscription \
acknowledgment: the pre-acknowledgment buffer is full"
);
}
}
Step::Notification(n) => return Some((n, state)),
Step::NotificationsClosed => {
state.notifs_open = false;
state.awaiting_ack = false;
}
Step::Response(Some(resp)) => {
state.response = Some(resp);
state.resp_rx = None;
state.awaiting_ack = false;
state.out.append(&mut state.held);
}
Step::Response(None) => {
state.resp_rx = None;
state.notifs_open = false;
state.awaiting_ack = false;
}
}
}
state.out.append(&mut state.held);
if let Some(msg) = state.out.pop_front() {
return Some((msg, state));
}
if let Some(rx) = state.resp_rx.take() {
state.response = rx.await.ok();
}
state.response.take().map(|resp| (resp, state))
})
}
fn parse_message(body: &Bytes) -> Result<Message, ErrorCode> {
serde_json::from_slice::<Message>(body).map_err(|e| match e.classify() {
serde_json::error::Category::Syntax | serde_json::error::Category::Eof => {
ErrorCode::ParseError
}
_ => ErrorCode::InvalidRequest,
})
}
fn get_or_create_mcp_session(
#[cfg_attr(not(feature = "legacy-spec"), allow(unused_variables))] headers: &HeaderMap,
) -> uuid::Uuid {
#[cfg(not(feature = "legacy-spec"))]
{
uuid::Uuid::new_v4()
}
#[cfg(feature = "legacy-spec")]
headers
.get(MCP_SESSION_ID)
.and_then(|v| v.to_str().ok())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
.unwrap_or_else(uuid::Uuid::new_v4)
}
fn reject_post_each(
msg: &Message,
verdict: impl Fn(&crate::types::Request) -> Error,
) -> Option<Message> {
use crate::types::{MessageBatch, MessageEnvelope};
match msg {
Message::Request(req) => Some(Message::Response(Response::error(req.id(), verdict(req)))),
Message::Batch(batch) => {
let items = batch
.iter()
.filter_map(|env| match env {
MessageEnvelope::Request(req) => Some(MessageEnvelope::Response(
Response::error(req.id(), verdict(req)),
)),
_ => None,
})
.collect::<Vec<_>>();
MessageBatch::new(items).map(Message::Batch).ok()
}
_ => None,
}
}
#[cfg(not(feature = "legacy-spec"))]
fn reject_post(msg: &Message, err: Error) -> Message {
let restated = reject_post_each(msg, |_| {
let copy = Error::new(err.code, err.to_string());
match err.data() {
Some(data) => copy.with_data(data.clone()),
None => copy,
}
});
restated.unwrap_or_else(|| Message::Response(Response::error(RequestId::Null, err)))
}
#[cfg(not(feature = "legacy-spec"))]
fn request_meta_error(req: &crate::types::Request, header_version: Option<&str>) -> Option<Error> {
req.required_meta_error()
.or_else(|| header_version_mismatch(req, header_version))
.or_else(|| req.unsupported_version_error())
}
#[cfg(not(feature = "legacy-spec"))]
fn header_version_mismatch(
req: &crate::types::Request,
header_version: Option<&str>,
) -> Option<Error> {
let stated = req.stated_protocol_version()?;
let header = header_version?;
(stated != header).then(|| {
Error::new(
ErrorCode::HeaderMismatch,
format!(
"Header mismatch: MCP-Protocol-Version header value {header:?} does not match body value {stated:?}"
),
)
})
}
#[cfg(not(feature = "legacy-spec"))]
fn name_source(req: &crate::types::Request) -> Option<(&str, bool)> {
#[cfg(feature = "tasks")]
{
use crate::types::task::commands as tasks;
if matches!(
req.method.as_str(),
tasks::GET | tasks::UPDATE | tasks::CANCEL
) {
let raw = req.params.as_ref()?.as_object()?.get("taskId")?.as_str()?;
return Some((raw, false));
}
}
let field = match req.method.as_str() {
crate::types::tool::commands::CALL | crate::types::prompt::commands::GET => "name",
crate::types::resource::commands::READ => "uri",
_ => return None,
};
let raw = req.params.as_ref()?.as_object()?.get(field)?.as_str()?;
Some((raw, true))
}
#[cfg(not(feature = "legacy-spec"))]
fn routing_header_error(req: &crate::types::Request, headers: &HeaderMap) -> Option<Error> {
let mismatch = |header: &str, stated: &str, body: &str| {
Some(Error::new(
ErrorCode::HeaderMismatch,
format!(
"Header mismatch: {header} header value {stated:?} does not match body value {body:?}"
),
))
};
let missing = |header: &str| {
Some(Error::new(
ErrorCode::HeaderMismatch,
format!("Missing or malformed {header} header"),
))
};
let method = crate::transport::http::MCP_METHOD;
match headers.get(method).and_then(|v| v.to_str().ok()) {
None => return missing(method),
Some(stated) if stated != req.method.as_str() => {
return mismatch(method, stated, &req.method);
}
Some(_) => {}
}
let name = crate::transport::http::MCP_NAME;
let stated = headers.get(name).and_then(|v| v.to_str().ok());
match (name_source(req), stated) {
(Some((_, true)), None) => missing(name),
(Some((body, _)), Some(stated)) => {
match crate::transport::http::decode_header_value(stated) {
Some(decoded) if decoded == body => None,
Some(decoded) => mismatch(name, &decoded, body),
None => missing(name),
}
}
_ => None,
}
}
#[cfg(not(feature = "legacy-spec"))]
fn dispatched_status(msg: &Message) -> http::StatusCode {
match msg {
Message::Response(Response::Err(err)) => match err.error.code {
ErrorCode::HeaderMismatch
| ErrorCode::MissingRequiredClientCapability
| ErrorCode::UnsupportedProtocolVersion => http::StatusCode::BAD_REQUEST,
ErrorCode::MethodNotFound => http::StatusCode::NOT_FOUND,
_ => http::StatusCode::OK,
},
_ => http::StatusCode::OK,
}
}
#[cfg(feature = "legacy-spec")]
fn dispatched_status(_msg: &Message) -> http::StatusCode {
http::StatusCode::OK
}
fn build_json_response(
status: http::StatusCode,
#[cfg_attr(not(feature = "legacy-spec"), allow(unused_variables))] session: uuid::Uuid,
body: &Message,
) -> HttpResponse {
let json = serde_json::to_vec(body).unwrap_or_default();
#[cfg_attr(not(feature = "legacy-spec"), allow(unused_mut))]
let mut resp = http::Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, "application/json")
.body(Bytes::from(json))
.unwrap_or_default();
#[cfg(feature = "legacy-spec")]
if let Ok(v) = HeaderValue::from_str(&session.to_string()) {
resp.headers_mut().insert(MCP_SESSION_ID, v);
}
resp
}
fn status_response(
status: http::StatusCode,
#[cfg_attr(not(feature = "legacy-spec"), allow(unused_variables))] session: uuid::Uuid,
) -> HttpResponse {
#[cfg_attr(not(feature = "legacy-spec"), allow(unused_mut))]
let mut resp = http::Response::builder()
.status(status)
.body(Bytes::new())
.unwrap_or_default();
#[cfg(feature = "legacy-spec")]
if let Ok(v) = HeaderValue::from_str(&session.to_string()) {
resp.headers_mut().insert(MCP_SESSION_ID, v);
}
resp
}
pub async fn handle_delete(req: HttpRequest, ctx: &HttpContext) -> HttpResponse {
if ctx.origin_policy.rejection(req.headers()).is_some() {
return http::Response::builder()
.status(http::StatusCode::FORBIDDEN)
.body(Bytes::new())
.unwrap_or_default();
}
let Some(id) = parse_session_id(req.headers()) else {
return http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Bytes::new())
.unwrap_or_default();
};
#[cfg(feature = "legacy-spec")]
if !ctx.sse_registry.is_live(&id) {
return http::Response::builder()
.status(http::StatusCode::NOT_FOUND)
.body(Bytes::new())
.unwrap_or_default();
}
#[cfg(feature = "tracing")]
crate::types::notification::fmt::LOG_REGISTRY.unregister(&id);
ctx.sse_registry.terminate(&id);
status_response(http::StatusCode::OK, id)
}
fn parse_session_id(headers: &HeaderMap) -> Option<uuid::Uuid> {
headers
.get(MCP_SESSION_ID)
.and_then(|v| v.to_str().ok())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
#[cfg(feature = "server-oauth")]
pub fn handle_oauth_metadata(ctx: &HttpContext) -> HttpResponse {
let Some(oauth) = &ctx.oauth else {
return http::Response::builder()
.status(http::StatusCode::NOT_FOUND)
.body(Bytes::new())
.unwrap_or_default();
};
http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.body(oauth.body.clone())
.unwrap_or_default()
}
#[cfg(feature = "server-oauth")]
pub fn handle_unauthorized(ctx: &HttpContext) -> HttpResponse {
let challenge = ctx
.oauth
.as_ref()
.map_or("Bearer", |oauth| &*oauth.challenge);
http::Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.header(http::header::WWW_AUTHENTICATE, challenge)
.body(Bytes::new())
.unwrap_or_default()
}
enum SseItem {
Tracked(u64, Arc<Message>),
Ephemeral(Box<Message>),
}
struct SseConnectionCleanup {
id: uuid::Uuid,
generation: u64,
registry: Arc<crate::shared::SseSessionRegistry>,
}
impl Drop for SseConnectionCleanup {
fn drop(&mut self) {
#[cfg(feature = "tracing")]
crate::types::notification::fmt::LOG_REGISTRY
.unregister_if_generation(&self.id, self.generation);
self.registry.unregister(&self.id, self.generation);
}
}
pub async fn handle_get_sse<E: HttpEngine>(
req: HttpRequest,
ctx: &HttpContext,
) -> StreamResponse<impl Stream<Item = E::SseEvent> + Send + 'static> {
if ctx.origin_policy.rejection(req.headers()).is_some() {
return StreamResponse::Complete(
http::Response::builder()
.status(http::StatusCode::FORBIDDEN)
.body(Bytes::new())
.unwrap_or_default(),
);
}
let Some(id) = parse_session_id(req.headers()) else {
return StreamResponse::Complete(
http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Bytes::new())
.unwrap_or_default(),
);
};
#[cfg(feature = "legacy-spec")]
if !ctx.sse_registry.is_live(&id) {
return StreamResponse::Complete(
http::Response::builder()
.status(http::StatusCode::NOT_FOUND)
.body(Bytes::new())
.unwrap_or_default(),
);
}
let (msg_tx, msg_rx) =
tokio::sync::mpsc::channel::<(u64, Arc<Message>)>(ctx.sse_live_queue_capacity);
let (_log_tx, log_rx) = tokio::sync::mpsc::channel::<Message>(ctx.sse_log_queue_capacity);
let generation = ctx.sse_registry.register(id, msg_tx);
#[cfg(feature = "tracing")]
crate::types::notification::fmt::LOG_REGISTRY.register(id, generation, _log_tx);
let last_seq: Option<u64> = req
.headers()
.get("last-event-id")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let replay = match last_seq {
Some(seq) => ctx.sse_registry.replay_since(&id, seq),
None => ctx.sse_registry.replay_all(&id),
};
let msg_stream = if replay.is_empty() {
Either::Left(ReceiverStream::new(msg_rx).map(|(seq, arc)| SseItem::Tracked(seq, arc)))
} else {
let replay_end_seq = replay.last().map(|(s, _)| *s).unwrap_or(0);
let replay_stream = stream::iter(replay).map(|(seq, arc)| SseItem::Tracked(seq, arc));
let live = ReceiverStream::new(msg_rx)
.filter(move |&(seq, _)| {
let keep = seq > replay_end_seq;
async move { keep }
})
.map(|(seq, arc)| SseItem::Tracked(seq, arc));
Either::Right(replay_stream.chain(live))
};
let log_stream = ReceiverStream::new(log_rx).map(|m| SseItem::Ephemeral(Box::new(m)));
let merged = stream::select(log_stream, msg_stream);
let cleanup = SseConnectionCleanup {
id,
generation,
registry: ctx.sse_registry.clone(),
};
let mut merged = Box::pin(merged);
let guarded = stream::poll_fn(move |cx| {
let _cleanup = &cleanup;
Pin::new(&mut merged).poll_next(cx)
})
.map(|item| match item {
SseItem::Tracked(seq, msg) => E::tracked_event(seq, &msg),
SseItem::Ephemeral(msg) => E::ephemeral_event(&msg),
});
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&id.to_string()) {
headers.insert(MCP_SESSION_ID, v);
}
StreamResponse::Stream {
headers,
stream: guarded,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::SseSessionRegistry;
use bytes::Bytes;
use dashmap::DashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
fn make_ctx() -> (
HttpContext,
mpsc::Receiver<Result<crate::types::Message, crate::error::Error>>,
) {
let (inbound_tx, inbound_rx) =
mpsc::channel::<Result<crate::types::Message, crate::error::Error>>(8);
let ctx = HttpContext {
addr: "127.0.0.1:0".into(),
endpoint: "/mcp".into(),
pending: Arc::new(DashMap::new()),
sse_registry: Arc::new(SseSessionRegistry::new(8)),
inbound_tx,
sse_live_queue_capacity: 64,
sse_log_queue_capacity: 64,
origin_policy: crate::transport::http::core::origin::OriginPolicy::Any,
#[cfg(feature = "server-oauth")]
oauth: None,
};
(ctx, inbound_rx)
}
#[cfg(not(feature = "legacy-spec"))]
fn meta() -> serde_json::Value {
serde_json::json!({
"io.modelcontextprotocol/protocolVersion": crate::LATEST_PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {}
})
}
fn make_request_body(method: &str) -> Bytes {
#[cfg(not(feature = "legacy-spec"))]
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
"id": 1,
"params": { "_meta": meta() }
});
#[cfg(feature = "legacy-spec")]
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
"id": 1
});
Bytes::from(serde_json::to_vec(&body).unwrap())
}
fn make_notification_body(method: &str) -> Bytes {
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": method
});
Bytes::from(serde_json::to_vec(&body).unwrap())
}
fn post_builder() -> http::request::Builder {
let b = http::Request::builder().method("POST").uri("/mcp");
#[cfg(not(feature = "legacy-spec"))]
let b = b.header(crate::transport::http::MCP_PROTOCOL_VERSION, "2026-07-28");
b
}
fn post_builder_for(method: &str) -> http::request::Builder {
let b = post_builder();
#[cfg(not(feature = "legacy-spec"))]
let b = b.header(crate::transport::http::MCP_METHOD, method);
#[cfg(feature = "legacy-spec")]
let _ = method;
b
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_missing_protocol_version() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.body(make_request_body("ping"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32020);
assert_eq!(body["id"], 1);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_unsupported_protocol_version() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.header(crate::transport::http::MCP_PROTOCOL_VERSION, "1999-01-01")
.body(make_request_body("ping"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32022);
assert_eq!(body["id"], 1);
assert_eq!(body["error"]["data"]["requested"], "1999-01-01");
assert_eq!(
body["error"]["data"]["supported"],
serde_json::json!(["2026-07-28"])
);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_every_request_of_a_batch_on_a_bad_version() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!([
{ "jsonrpc": "2.0", "method": "ping", "id": 1, "params": { "_meta": meta() } },
{ "jsonrpc": "2.0", "method": "notifications/initialized" },
{ "jsonrpc": "2.0", "method": "ping", "id": 2, "params": { "_meta": meta() } },
]);
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.header(crate::transport::http::MCP_PROTOCOL_VERSION, "1999-01-01")
.body(Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
let items = body.as_array().expect("a batch is answered with a batch");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[1]["id"], 2);
for item in items {
assert_eq!(item["error"]["code"], -32022);
assert_eq!(item["error"]["data"]["requested"], "1999-01-01");
}
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn a_bad_version_outranks_an_unparseable_body() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.header(crate::transport::http::MCP_PROTOCOL_VERSION, "1999-01-01")
.body(Bytes::from_static(b"{ not json"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32022);
assert!(body["id"].is_null());
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_legacy_protocol_version() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.header(crate::transport::http::MCP_PROTOCOL_VERSION, "2025-06-18")
.body(make_request_body("ping"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32022);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_a_version_this_build_does_not_serve() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": "2025-06-18",
"io.modelcontextprotocol/clientCapabilities": {}
} }
});
let req = http::Request::builder()
.method("POST")
.uri("/mcp")
.header(crate::transport::http::MCP_PROTOCOL_VERSION, "2025-06-18")
.header(crate::transport::http::MCP_METHOD, "tools/list")
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32022);
assert_eq!(body["error"]["data"]["requested"], "2025-06-18");
assert_eq!(
body["error"]["data"]["supported"],
serde_json::json!(["2026-07-28"])
);
}
#[tokio::test]
async fn origin_gate_rejects_a_rebound_name() {
let (mut ctx, _rx) = make_ctx();
ctx.origin_policy = crate::transport::http::core::origin::OriginPolicy::Loopback;
let rebound = post_builder()
.header("host", "evil.example.com")
.header("origin", "http://evil.example.com")
.body(make_request_body("tools/list"))
.unwrap();
let resp = handle_post(rebound, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::FORBIDDEN);
let local = post_builder()
.header("host", "127.0.0.1:3000")
.header("origin", "http://127.0.0.1:3000")
.body(bytes::Bytes::from_static(b"{ not json"))
.unwrap();
let resp = handle_post(local, &ctx).await;
assert_ne!(resp.status(), http::StatusCode::FORBIDDEN);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_a_body_version_disagreeing_with_the_header() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": "v999.0.0",
"io.modelcontextprotocol/clientCapabilities": {}
} }
});
let req = post_builder()
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32020);
assert_eq!(body["id"], 1);
}
#[cfg(not(feature = "legacy-spec"))]
#[test]
fn method_not_found_is_a_404() {
let status = |code: ErrorCode| {
dispatched_status(&Message::Response(Response::error(
RequestId::Number(1),
Error::from(code),
)))
};
assert_eq!(
status(ErrorCode::MethodNotFound),
http::StatusCode::NOT_FOUND
);
assert_eq!(
status(ErrorCode::HeaderMismatch),
http::StatusCode::BAD_REQUEST
);
assert_eq!(
status(ErrorCode::UnsupportedProtocolVersion),
http::StatusCode::BAD_REQUEST
);
assert_eq!(
status(ErrorCode::MissingRequiredClientCapability),
http::StatusCode::BAD_REQUEST
);
assert_eq!(status(ErrorCode::InternalError), http::StatusCode::OK);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_a_request_missing_required_meta() {
let cases = [
serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }),
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}
}),
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": { "io.modelcontextprotocol/clientCapabilities": {} } }
}),
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": crate::LATEST_PROTOCOL_VERSION
} }
}),
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": 20260728,
"io.modelcontextprotocol/clientCapabilities": {}
} }
}),
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": crate::LATEST_PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": "elicitation"
} }
}),
];
for case in cases {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.body(bytes::Bytes::from(serde_json::to_vec(&case).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(
resp.status(),
http::StatusCode::BAD_REQUEST,
"must answer 400: {case}"
);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32602, "must be malformed params");
}
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn accepts_a_request_declaring_no_capabilities() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "_meta": meta() }
});
let req = post_builder_for("tools/list")
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let ctx = std::sync::Arc::new(ctx);
let ctx_clone = ctx.clone();
let _h = tokio::spawn(async move { handle_post(req, &ctx_clone).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(ctx.pending.len(), 1);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_routing_headers_that_do_not_describe_the_body() {
let call = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "safe_tool", "arguments": {}, "_meta": meta() }
});
let cases: Vec<Vec<(&str, &str)>> = vec![
vec![("Mcp-Method", "tools/call"), ("Mcp-Name", "allowed_tool")],
vec![("Mcp-Method", "tools/list"), ("Mcp-Name", "safe_tool")],
vec![("Mcp-Name", "safe_tool")],
vec![("Mcp-Method", "tools/call")],
vec![],
vec![("Mcp-Method", "tools/call"), ("Mcp-Name", "=?base64?%%%?=")],
];
for case in cases {
let (ctx, _rx) = make_ctx();
let mut req = post_builder();
for (name, value) in &case {
req = req.header(*name, *value);
}
let req = req
.body(bytes::Bytes::from(serde_json::to_vec(&call).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(
resp.status(),
http::StatusCode::BAD_REQUEST,
"must answer 400: {case:?}"
);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32020, "must be a header mismatch");
}
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn accepts_a_base64_encoded_name_matching_the_body() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "resources/read",
"params": { "uri": "file:///café.txt", "_meta": meta() }
});
let req = post_builder()
.header(crate::transport::http::MCP_METHOD, "resources/read")
.header(
crate::transport::http::MCP_NAME,
"=?base64?ZmlsZTovLy9jYWbDqS50eHQ=?=",
)
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let ctx = std::sync::Arc::new(ctx);
let ctx_clone = ctx.clone();
let _h = tokio::spawn(async move { handle_post(req, &ctx_clone).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(ctx.pending.len(), 1, "the request must reach dispatch");
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_a_notification_whose_method_header_disagrees() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.header(crate::transport::http::MCP_METHOD, "notifications/progress")
.body(make_notification_body("notifications/cancelled"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32020);
assert!(body["id"].is_null(), "a notification has no id: {body}");
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn accepts_a_notification_without_a_method_header() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.body(make_notification_body("notifications/cancelled"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::ACCEPTED);
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_routing_headers_on_a_batch() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": meta() } },
{
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "evil", "arguments": {}, "_meta": meta() }
}
]);
let req = post_builder()
.header(crate::transport::http::MCP_METHOD, "tools/list")
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
let items = body.as_array().expect("a batch is answered with a batch");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[1]["id"], 2);
for item in items {
assert_eq!(item["error"]["code"], -32020);
}
}
#[cfg(not(feature = "legacy-spec"))]
#[tokio::test]
async fn rejects_a_batched_body_version_disagreeing_with_the_header() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!([
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": { "_meta": meta() } },
{
"jsonrpc": "2.0", "id": 2, "method": "prompts/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": "2025-06-18",
"io.modelcontextprotocol/clientCapabilities": {}
} }
}
]);
let req = post_builder()
.body(bytes::Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
let items = body.as_array().expect("a batch is answered with a batch");
assert_eq!(items.len(), 2);
assert_eq!(items[1]["id"], 2);
assert_eq!(items[1]["error"]["code"], -32020);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[0]["error"]["code"], -32600);
}
#[cfg(not(feature = "legacy-spec"))]
#[test]
fn spec_error_codes_map_to_400() {
for code in [
ErrorCode::HeaderMismatch,
ErrorCode::MissingRequiredClientCapability,
ErrorCode::UnsupportedProtocolVersion,
] {
let msg = Message::Response(Response::error(
RequestId::Number(1),
Error::new(code, "nope"),
));
assert_eq!(
dispatched_status(&msg),
http::StatusCode::BAD_REQUEST,
"{code:?} must answer 400"
);
}
let msg = Message::Response(Response::error(
RequestId::Number(1),
Error::new(ErrorCode::InvalidParams, "nope"),
));
assert_eq!(dispatched_status(&msg), http::StatusCode::OK);
}
#[tokio::test]
async fn notification_returns_202_without_pending_entry() {
let (ctx, mut _rx) = make_ctx();
let req = post_builder()
.body(make_notification_body("notifications/cancelled"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::ACCEPTED);
assert!(
ctx.pending.is_empty(),
"no pending oneshot for notifications"
);
}
#[tokio::test]
async fn malformed_json_returns_parse_error_response() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.body(Bytes::from_static(b"not json"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32700);
}
#[tokio::test]
async fn invalid_message_shape_returns_invalid_request() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.body(Bytes::from_static(b"{\"valid_json\": true}"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["error"]["code"], -32600);
}
#[tokio::test]
async fn init_request_pre_registers_session() {
let (ctx, _rx) = make_ctx();
let req = post_builder_for(crate::commands::INIT)
.body(make_request_body(crate::commands::INIT))
.unwrap();
let ctx_arc = std::sync::Arc::new(ctx);
let ctx_clone = ctx_arc.clone();
let _h = tokio::spawn(async move {
handle_post(req, &ctx_clone).await;
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(ctx_arc.pending.len(), 1);
}
#[tokio::test]
async fn delete_without_session_id_returns_400() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("DELETE")
.uri("/mcp")
.body(Bytes::new())
.unwrap();
let resp = handle_delete(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST);
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn delete_with_session_id_echoes_it_back() {
let (ctx, _rx) = make_ctx();
let id = uuid::Uuid::new_v4();
ctx.sse_registry.pre_register(id);
let req = http::Request::builder()
.method("DELETE")
.uri("/mcp")
.header(MCP_SESSION_ID, id.to_string())
.body(Bytes::new())
.unwrap();
let resp = handle_delete(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(MCP_SESSION_ID)
.and_then(|v| v.to_str().ok()),
Some(id.to_string().as_str())
);
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn a_terminated_session_is_gone_for_every_verb() {
let (ctx, _rx) = make_ctx();
let id = uuid::Uuid::new_v4();
ctx.sse_registry.pre_register(id);
ctx.sse_registry.terminate(&id);
let post = post_builder()
.header(MCP_SESSION_ID, id.to_string())
.body(make_request_body("tools/list"))
.unwrap();
assert_eq!(
handle_post(post, &ctx).await.status(),
http::StatusCode::NOT_FOUND
);
let delete = http::Request::builder()
.method("DELETE")
.uri("/mcp")
.header(MCP_SESSION_ID, id.to_string())
.body(Bytes::new())
.unwrap();
assert_eq!(
handle_delete(delete, &ctx).await.status(),
http::StatusCode::NOT_FOUND
);
let get = http::Request::builder()
.method("GET")
.uri("/mcp")
.header(MCP_SESSION_ID, id.to_string())
.body(Bytes::new())
.unwrap();
match handle_get_sse::<TestEngine>(get, &ctx).await {
StreamResponse::Complete(r) => assert_eq!(r.status(), http::StatusCode::NOT_FOUND),
StreamResponse::Stream { .. } => panic!("a terminated session opened a stream"),
}
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn a_404_on_a_dead_session_is_addressed_to_the_caller() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.header(MCP_SESSION_ID, uuid::Uuid::new_v4().to_string())
.body(make_request_body("tools/list"))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
let body: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body["id"], 1);
assert_eq!(body["error"]["code"], i32::from(ErrorCode::InvalidRequest));
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn a_dead_session_answers_a_notification_with_status_alone() {
let dead = uuid::Uuid::new_v4().to_string();
let notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": 1 }
});
let batch = serde_json::json!([notification, notification]);
for body in [notification.clone(), batch] {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.header(MCP_SESSION_ID, &dead)
.body(Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
assert!(
resp.body().is_empty(),
"a notification must not be answered, rejection included; got {}",
String::from_utf8_lossy(resp.body())
);
assert!(
!resp.headers().contains_key(http::header::CONTENT_TYPE),
"an empty body claims no content type"
);
}
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn a_dead_session_answers_only_the_requests_in_a_mixed_batch() {
let (ctx, _rx) = make_ctx();
let body = serde_json::json!([
{ "jsonrpc": "2.0", "method": "notifications/cancelled", "params": { "requestId": 9 } },
{ "jsonrpc": "2.0", "method": "tools/list", "id": 7 }
]);
let req = post_builder()
.header(MCP_SESSION_ID, uuid::Uuid::new_v4().to_string())
.body(Bytes::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = handle_post(req, &ctx).await;
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
let replies: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
let replies = replies.as_array().expect("a batch is answered by a batch");
assert_eq!(replies.len(), 1, "only the request is answered");
assert_eq!(replies[0]["id"], 7);
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn an_initialize_naming_an_unknown_session_still_opens_one() {
let (ctx, _rx) = make_ctx();
let id = uuid::Uuid::new_v4();
let req = post_builder()
.header(MCP_SESSION_ID, id.to_string())
.body(make_request_body(crate::commands::INIT))
.unwrap();
let ctx = Arc::new(ctx);
let ctx_clone = ctx.clone();
let _h = tokio::spawn(async move { handle_post(req, &ctx_clone).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
ctx.sse_registry.is_live(&id),
"the handshake did not open the session it named"
);
}
#[cfg(feature = "legacy-spec")]
#[tokio::test]
async fn a_post_without_a_session_header_is_not_judged_against_one() {
let (ctx, _rx) = make_ctx();
let req = post_builder()
.body(make_notification_body("notifications/initialized"))
.unwrap();
assert_eq!(
handle_post(req, &ctx).await.status(),
http::StatusCode::ACCEPTED
);
}
struct TestEngine;
impl super::HttpEngine for TestEngine {
type Request = HttpRequest;
type Response = HttpResponse;
type SseEvent = (Option<u64>, String);
async fn adapt_request(_req: Self::Request) -> Result<HttpRequest, crate::error::Error> {
unreachable!()
}
fn adapt_response(_resp: HttpResponse) -> Self::Response {
unreachable!()
}
fn tracked_event(seq: u64, msg: &Message) -> Self::SseEvent {
(Some(seq), serde_json::to_string(msg).unwrap())
}
fn ephemeral_event(msg: &Message) -> Self::SseEvent {
(None, serde_json::to_string(msg).unwrap())
}
async fn run(
self,
_ctx: HttpContext,
_token: tokio_util::sync::CancellationToken,
) -> Result<(), crate::error::Error> {
unreachable!()
}
}
#[tokio::test]
async fn get_without_session_id_returns_400() {
let (ctx, _rx) = make_ctx();
let req = http::Request::builder()
.method("GET")
.uri("/mcp")
.body(Bytes::new())
.unwrap();
let resp = handle_get_sse::<TestEngine>(req, &ctx).await;
match resp {
StreamResponse::Complete(r) => assert_eq!(r.status(), http::StatusCode::BAD_REQUEST),
StreamResponse::Stream { .. } => panic!("expected Status, got Stream"),
}
}
#[tokio::test]
async fn get_with_session_returns_stream_with_session_header() {
let (ctx, _rx) = make_ctx();
let id = uuid::Uuid::new_v4();
ctx.sse_registry.pre_register(id);
let req = http::Request::builder()
.method("GET")
.uri("/mcp")
.header(MCP_SESSION_ID, id.to_string())
.body(Bytes::new())
.unwrap();
let resp = handle_get_sse::<TestEngine>(req, &ctx).await;
match resp {
StreamResponse::Stream { headers, stream: _ } => {
assert_eq!(
headers.get(MCP_SESSION_ID).and_then(|v| v.to_str().ok()),
Some(id.to_string().as_str())
);
}
StreamResponse::Complete(_) => panic!("expected Stream, got Status"),
}
}
#[cfg(feature = "server-oauth")]
fn make_oauth_ctx() -> HttpContext {
use crate::transport::http::core::oauth::OAuthResourceOptions;
let (mut ctx, _rx) = make_ctx();
let oauth = OAuthResourceOptions::default()
.with_authorization_servers(["https://auth.example.com"])
.resolve("http://127.0.0.1:3000/mcp")
.unwrap();
ctx.oauth = Some(oauth);
ctx
}
#[cfg(feature = "server-oauth")]
#[test]
fn oauth_metadata_serves_the_configured_document() {
let ctx = make_oauth_ctx();
let resp = handle_oauth_metadata(&ctx);
assert_eq!(resp.status(), http::StatusCode::OK);
assert_eq!(
resp.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/json")
);
let doc: serde_json::Value = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(doc["resource"], "http://127.0.0.1:3000/mcp");
assert_eq!(doc["authorization_servers"][0], "https://auth.example.com");
}
#[cfg(feature = "server-oauth")]
#[test]
fn oauth_metadata_without_config_returns_404() {
let (ctx, _rx) = make_ctx();
let resp = handle_oauth_metadata(&ctx);
assert_eq!(resp.status(), http::StatusCode::NOT_FOUND);
}
#[cfg(feature = "server-oauth")]
#[test]
fn unauthorized_challenge_points_at_resource_metadata() {
use crate::transport::http::core::oauth::BearerChallenge;
let ctx = make_oauth_ctx();
let resp = handle_unauthorized(&ctx);
assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED);
let header = resp
.headers()
.get(http::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.unwrap();
let challenge = BearerChallenge::parse(header).unwrap();
assert_eq!(
challenge.resource_metadata(),
Some("http://127.0.0.1:3000/.well-known/oauth-protected-resource/mcp")
);
}
#[cfg(feature = "server-oauth")]
#[test]
fn unauthorized_without_config_sends_bare_bearer_challenge() {
let (ctx, _rx) = make_ctx();
let resp = handle_unauthorized(&ctx);
assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(http::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok()),
Some("Bearer")
);
}
}