use core::num::NonZeroU64;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use super::JSON_MEDIA_TYPE;
use crate::domain::EventId;
use crate::domain::jsonrpc::WireId;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Refusal {
#[error("the Mcp-Session-Id header is not a well-formed session id")]
MalformedSessionId,
#[error("this request requires an Mcp-Session-Id header")]
MissingSession,
#[error("no session is registered under that Mcp-Session-Id")]
UnknownSession,
#[error("this endpoint does not offer {method} for {reason}")]
MethodNotAllowed {
method: &'static str,
reason: &'static str,
},
#[error("the MCP-Protocol-Version header is not usable: {reason}")]
ProtocolHeader {
reason: String,
},
#[error("the request body exceeds the configured limit")]
BodyTooLarge,
#[error("this endpoint requires authentication")]
Unauthenticated,
#[error("this endpoint requires a scope the caller does not hold")]
Forbidden,
#[error("this request's Origin is not one this server serves")]
ForeignOrigin,
#[error("this subject exceeded its request rate; retry after {retry_after}s")]
RateLimited {
retry_after: NonZeroU64,
},
#[error("this handler did not answer inside the configured request deadline")]
Timeout,
#[error("the request body is not a decodable JSON-RPC frame: {reason}")]
MalformedBody {
reason: String,
},
#[error("the stream dropped events before the requested cursor")]
StreamGap {
requested: EventId,
evicted_through: EventId,
last: EventId,
},
#[error("the requested cursor is ahead of this stream")]
UnknownCursor {
requested: EventId,
last: EventId,
},
#[error("the Last-Event-ID header is not a well-formed event cursor")]
MalformedCursor,
#[error("{0}")]
Handler(String),
}
impl Refusal {
#[must_use]
pub const fn status(&self) -> StatusCode {
match self {
Self::MalformedSessionId
| Self::MissingSession
| Self::ProtocolHeader { .. }
| Self::MalformedBody { .. }
| Self::MalformedCursor => StatusCode::BAD_REQUEST,
Self::UnknownSession => StatusCode::NOT_FOUND,
Self::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED,
Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
Self::Unauthenticated => StatusCode::UNAUTHORIZED,
Self::Forbidden | Self::ForeignOrigin => StatusCode::FORBIDDEN,
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
Self::Timeout => StatusCode::SERVICE_UNAVAILABLE,
Self::StreamGap { .. } | Self::UnknownCursor { .. } => StatusCode::CONFLICT,
Self::Handler(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub const fn code(&self) -> i64 {
match self {
Self::MalformedBody { .. } => -32700,
Self::MalformedSessionId | Self::MissingSession | Self::ProtocolHeader { .. } => -32600,
Self::MethodNotAllowed { .. } => -32601,
Self::Handler(_) => -32603,
Self::UnknownSession => -32001,
Self::BodyTooLarge => -32002,
Self::Unauthenticated => -32003,
Self::Forbidden => -32004,
Self::StreamGap { .. } => -32005,
Self::UnknownCursor { .. } => -32006,
Self::MalformedCursor => -32007,
Self::ForeignOrigin => -32008,
Self::RateLimited { .. } => -32009,
Self::Timeout => -32010,
}
}
#[must_use]
pub fn body(&self) -> Value {
self.body_with_id(None)
}
pub fn body_with_id(&self, id: Option<&WireId<'_>>) -> Value {
let data = match self {
Self::StreamGap {
requested,
evicted_through,
last,
} => json!({
"error": "stream_gap",
"requested": requested.get(),
"evicted_through": evicted_through.get(),
"last": last.get(),
}),
Self::UnknownCursor { requested, last } => json!({
"error": "unknown_cursor",
"requested": requested.get(),
"last": last.get(),
}),
other => json!({ "error": slug(other) }),
};
let id = match id {
Some(WireId::Number(value)) => Value::from(*value),
Some(WireId::Text(text)) => Value::from(text.as_ref()),
None => Value::Null,
};
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": self.code(),
"message": self.to_string(),
"data": data,
},
})
}
}
const fn slug(refusal: &Refusal) -> &'static str {
match refusal {
Refusal::MalformedSessionId => "malformed_session_id",
Refusal::MissingSession => "missing_session",
Refusal::UnknownSession => "unknown_session",
Refusal::MethodNotAllowed { .. } => "method_not_allowed",
Refusal::ProtocolHeader { .. } => "protocol_header",
Refusal::BodyTooLarge => "body_too_large",
Refusal::Unauthenticated => "unauthenticated",
Refusal::Forbidden => "forbidden",
Refusal::MalformedBody { .. } => "malformed_body",
Refusal::StreamGap { .. } => "stream_gap",
Refusal::UnknownCursor { .. } => "unknown_cursor",
Refusal::MalformedCursor => "malformed_cursor",
Refusal::ForeignOrigin => "foreign_origin",
Refusal::RateLimited { .. } => "rate_limited",
Refusal::Timeout => "request_timeout",
Refusal::Handler(_) => "handler_error",
}
}
impl Refusal {
pub fn into_response_with_id(self, id: Option<&WireId<'_>>) -> Response {
let status = self.status();
let body = serde_json::to_vec(&self.body_with_id(id)).unwrap_or_else(|_| Vec::new());
let mut response = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, JSON_MEDIA_TYPE)
.body(axum::body::Body::from(body))
.unwrap_or_else(|_| {
let mut fallback = Response::new(axum::body::Body::empty());
*fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
fallback
});
if let Self::MethodNotAllowed { method, .. } = &self
&& let Ok(value) = HeaderValue::from_str(method)
{
response.headers_mut().insert(header::ALLOW, value);
}
if let Self::RateLimited { retry_after } = &self
&& let Ok(value) = HeaderValue::from_str(&retry_after.get().to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}
}
impl IntoResponse for Refusal {
fn into_response(self) -> Response {
self.into_response_with_id(None)
}
}
#[cfg(test)]
mod tests {
use crate::domain::jsonrpc::WireId;
#[test]
fn a_refusal_echoes_an_id_when_one_was_detected() {
let numeric = Refusal::MissingSession.body_with_id(Some(&WireId::Number(7)));
assert_eq!(
numeric.get("id"),
Some(&serde_json::json!(7)),
"a refusal to request 7 must answer 7, not null",
);
let textual = Refusal::MissingSession.body_with_id(Some(&WireId::text("r1")));
assert_eq!(
textual.get("id"),
Some(&serde_json::json!("r1")),
"a string id must stay a string",
);
assert_eq!(
Refusal::MissingSession.body().get("id"),
Some(&serde_json::Value::Null),
"with no id detected, the answer is null",
);
}
use assert_matches::assert_matches;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde_json::Value;
use crate::domain::EventId;
use super::{Refusal, slug};
type TestError = Box<dyn std::error::Error>;
#[test]
fn every_variant_maps_to_its_status() {
let cases = [
(Refusal::MalformedSessionId, StatusCode::BAD_REQUEST),
(Refusal::MissingSession, StatusCode::BAD_REQUEST),
(
Refusal::ProtocolHeader {
reason: "unknown version".to_owned(),
},
StatusCode::BAD_REQUEST,
),
(
Refusal::MalformedBody {
reason: "not json".to_owned(),
},
StatusCode::BAD_REQUEST,
),
(Refusal::UnknownSession, StatusCode::NOT_FOUND),
(
Refusal::MethodNotAllowed {
method: "GET",
reason: "no stream",
},
StatusCode::METHOD_NOT_ALLOWED,
),
(Refusal::BodyTooLarge, StatusCode::PAYLOAD_TOO_LARGE),
(Refusal::Unauthenticated, StatusCode::UNAUTHORIZED),
(Refusal::Forbidden, StatusCode::FORBIDDEN),
(
Refusal::StreamGap {
requested: EventId::new(1),
evicted_through: EventId::new(1),
last: EventId::new(1),
},
StatusCode::CONFLICT,
),
(
Refusal::UnknownCursor {
requested: EventId::new(1),
last: EventId::new(1),
},
StatusCode::CONFLICT,
),
(
Refusal::Handler("boom".to_owned()),
StatusCode::INTERNAL_SERVER_ERROR,
),
];
for (refusal, expected) in cases {
assert_eq!(refusal.status(), expected, "wrong status for {refusal:?}");
}
}
#[test]
fn the_body_is_a_json_rpc_error_object() -> Result<(), TestError> {
for refusal in [
Refusal::MalformedSessionId,
Refusal::UnknownSession,
Refusal::Unauthenticated,
Refusal::Forbidden,
Refusal::BodyTooLarge,
] {
let body = refusal.body();
assert_eq!(body.get("jsonrpc").and_then(Value::as_str), Some("2.0"));
let error = body
.get("error")
.ok_or("no error member")?
.as_object()
.ok_or("error is not an object")?;
let code = error
.get("code")
.and_then(Value::as_i64)
.ok_or("code is not an integer")?;
assert!(code < 0, "a server error code must be negative: {code}");
let message = error
.get("message")
.and_then(Value::as_str)
.ok_or("message is not a string")?;
assert!(!message.is_empty());
assert!(body.get("id").is_some_and(Value::is_null));
}
Ok(())
}
#[test]
fn every_variant_has_its_own_code() {
let distinct = [
Refusal::UnknownSession,
Refusal::BodyTooLarge,
Refusal::Unauthenticated,
Refusal::Forbidden,
Refusal::StreamGap {
requested: EventId::new(1),
evicted_through: EventId::new(1),
last: EventId::new(1),
},
Refusal::UnknownCursor {
requested: EventId::new(1),
last: EventId::new(1),
},
];
for (index, refusal) in distinct.iter().enumerate() {
for (other_index, other) in distinct.iter().enumerate() {
if index != other_index {
assert_ne!(
refusal.code(),
other.code(),
"{refusal:?} and {other:?} share a code"
);
}
}
}
assert_eq!(Refusal::MalformedSessionId.code(), -32600);
assert_eq!(Refusal::MissingSession.code(), -32600);
assert_ne!(
Refusal::MalformedSessionId.code(),
Refusal::MalformedBody {
reason: String::new()
}
.code()
);
assert_ne!(Refusal::Unauthenticated.code(), Refusal::Forbidden.code());
assert_eq!(Refusal::Unauthenticated.status(), StatusCode::UNAUTHORIZED);
assert_eq!(Refusal::Forbidden.status(), StatusCode::FORBIDDEN);
assert_matches!(Refusal::Unauthenticated.to_string(), String { .. });
}
#[test]
fn the_two_session_faults_are_distinguished() {
assert_eq!(
Refusal::MalformedSessionId.status(),
StatusCode::BAD_REQUEST,
);
assert_eq!(Refusal::UnknownSession.status(), StatusCode::NOT_FOUND);
assert_ne!(
Refusal::MalformedSessionId.code(),
Refusal::UnknownSession.code(),
);
}
#[test]
fn a_gap_names_both_watermarks() -> Result<(), TestError> {
let refusal = Refusal::StreamGap {
requested: EventId::new(3),
evicted_through: EventId::new(9),
last: EventId::new(42),
};
let data = refusal
.body()
.get("error")
.and_then(|error| error.get("data"))
.cloned()
.ok_or("no data member")?;
assert_eq!(
data.get("error").and_then(Value::as_str),
Some("stream_gap")
);
assert_eq!(data.get("requested").and_then(Value::as_u64), Some(3));
assert_eq!(data.get("evicted_through").and_then(Value::as_u64), Some(9));
assert_eq!(data.get("last").and_then(Value::as_u64), Some(42));
Ok(())
}
#[test]
fn a_gap_and_an_unknown_cursor_are_different_faults() {
let gap = Refusal::StreamGap {
requested: EventId::new(3),
evicted_through: EventId::new(9),
last: EventId::new(42),
};
let unknown = Refusal::UnknownCursor {
requested: EventId::new(50),
last: EventId::new(42),
};
assert_eq!(gap.status(), StatusCode::CONFLICT);
assert_eq!(unknown.status(), StatusCode::CONFLICT);
assert_ne!(slug(&gap), slug(&unknown));
assert_ne!(gap.code(), unknown.code());
assert_eq!(
unknown
.body()
.get("error")
.and_then(|error| error.get("data"))
.and_then(|data| data.get("error"))
.and_then(Value::as_str),
Some("unknown_cursor"),
);
}
#[test]
fn a_refused_method_advertises_the_allowed_one() {
let response = Refusal::MethodNotAllowed {
method: "POST",
reason: "this session has no server-initiated stream",
}
.into_response();
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
response
.headers()
.get(axum::http::header::ALLOW)
.and_then(|value| value.to_str().ok()),
Some("POST"),
);
}
#[test]
fn every_variant_has_a_stable_slug() {
let cases = [
(Refusal::MalformedSessionId, "malformed_session_id"),
(Refusal::MissingSession, "missing_session"),
(Refusal::UnknownSession, "unknown_session"),
(
Refusal::MethodNotAllowed {
method: "GET",
reason: "no stream",
},
"method_not_allowed",
),
(
Refusal::ProtocolHeader {
reason: "unknown".to_owned(),
},
"protocol_header",
),
(Refusal::BodyTooLarge, "body_too_large"),
(Refusal::Unauthenticated, "unauthenticated"),
(Refusal::Forbidden, "forbidden"),
(
Refusal::MalformedBody {
reason: "not json".to_owned(),
},
"malformed_body",
),
(
Refusal::StreamGap {
requested: EventId::new(1),
evicted_through: EventId::new(1),
last: EventId::new(1),
},
"stream_gap",
),
(
Refusal::UnknownCursor {
requested: EventId::new(1),
last: EventId::new(1),
},
"unknown_cursor",
),
(Refusal::Handler("boom".to_owned()), "handler_error"),
];
for (refusal, expected) in cases {
assert_eq!(slug(&refusal), expected);
assert!(!expected.is_empty());
assert_eq!(
refusal
.body()
.get("error")
.and_then(|error| error.get("data"))
.and_then(|data| data.get("error"))
.and_then(Value::as_str),
Some(expected),
"body slug disagrees for {expected}",
);
}
}
#[test]
fn the_message_in_the_body_is_the_display_text() {
let refusal = Refusal::Forbidden;
let expected = refusal.to_string();
assert_eq!(
refusal
.body()
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str),
Some(expected.as_str()),
);
}
}