use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Json,
},
routing::{get, post},
Router,
};
use futures_util::stream::Stream;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::convert::Infallible;
use std::time::Duration;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use car_mcp::error_codes::PARSE as E_PARSE;
use car_mcp::{Request as McpRequest, Server as McpServer};
const SESSION_HEADER: &str = "mcp-session-id";
const PROTOCOL_HEADER: &str = "mcp-protocol-version";
const SSE_KEEPALIVE_SECS: u64 = 30;
fn origin_allowed(origin: &str) -> bool {
if origin.is_empty() || origin == "null" {
return false;
}
let Some((scheme, rest)) = origin.split_once("://") else {
return false;
};
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return false;
}
let host = match rest.strip_prefix('[') {
Some(after_bracket) => match after_bracket.split_once(']') {
Some((inner, tail)) if tail.is_empty() || tail.starts_with(':') => inner,
_ => return false,
},
None => match rest.split_once(':') {
Some((h, _port)) => h,
None => rest,
},
};
let host = host.to_ascii_lowercase();
if host == "localhost" || host == "::1" {
return true;
}
matches!(host.parse::<std::net::IpAddr>(), Ok(ip) if ip.is_loopback())
}
fn check_origin(headers: &HeaderMap) -> Result<(), axum::response::Response> {
let Some(raw) = headers.get(axum::http::header::ORIGIN) else {
return Ok(());
};
let allowed = raw.to_str().map(origin_allowed).unwrap_or(false);
if allowed {
return Ok(());
}
let shown = raw.to_str().unwrap_or("<non-utf8>");
tracing::warn!(origin = %shown, "MCP request rejected: origin not allowed");
Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": "origin not allowed" })),
)
.into_response())
}
fn check_protocol_version(headers: &HeaderMap) -> Result<(), axum::response::Response> {
let Some(raw) = headers.get(PROTOCOL_HEADER) else {
return Ok(());
};
if raw
.to_str()
.map(|v| car_mcp::SUPPORTED_VERSIONS.contains(&v))
.unwrap_or(false)
{
return Ok(());
}
let shown = raw.to_str().unwrap_or("<non-utf8>");
tracing::warn!(version = %shown, "MCP request rejected: unsupported protocol version");
Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "unsupported MCP-Protocol-Version",
"requested": shown,
"supported": car_mcp::SUPPORTED_VERSIONS,
})),
)
.into_response())
}
pub struct McpSession {
tx: mpsc::Sender<String>,
}
pub type SessionMap = Mutex<HashMap<String, McpSession>>;
#[derive(Clone)]
struct McpState {
server: Arc<McpServer>,
sessions: Arc<SessionMap>,
}
pub async fn start_mcp(
server: Arc<McpServer>,
addr: SocketAddr,
) -> Result<(SocketAddr, JoinHandle<()>, Arc<SessionMap>), String> {
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("bind {addr}: {e}"))?;
let bound = listener
.local_addr()
.map_err(|e| format!("local_addr: {e}"))?;
let sessions: Arc<SessionMap> = Arc::new(Mutex::new(HashMap::new()));
let state = McpState {
server,
sessions: sessions.clone(),
};
let app: Router = Router::new()
.route("/mcp", post(handle_mcp_post).get(handle_mcp_get))
.route("/mcp/health", get(handle_health))
.with_state(state);
let task = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::warn!(error = %e, "mcp HTTP server exited");
}
});
Ok((bound, task, sessions))
}
async fn handle_health() -> impl IntoResponse {
Json(json!({
"status": "ok",
"protocol_version": car_mcp::PROTOCOL_VERSION,
"server_name": car_mcp::SERVER_NAME,
}))
}
async fn handle_mcp_get(
State(state): State<McpState>,
headers: HeaderMap,
) -> axum::response::Response {
if let Err(resp) = check_origin(&headers) {
return resp;
}
if let Err(resp) = check_protocol_version(&headers) {
return resp;
}
let session_id = headers
.get(SESSION_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(uuid_v4_simple);
let (tx, rx) = mpsc::channel::<String>(64);
{
let mut sessions = state.sessions.lock().await;
sessions.insert(session_id.clone(), McpSession { tx });
}
tracing::debug!(%session_id, "MCP SSE stream opened");
let init_event = serde_json::to_string(&json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": { "session_id": session_id.clone() },
}))
.unwrap_or_else(|_| "{}".to_string());
let stream =
async_stream::stream_init_event(init_event, rx, state.sessions.clone(), session_id.clone());
Sse::new(stream)
.keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(SSE_KEEPALIVE_SECS))
.text("ping"),
)
.into_response()
}
pub async fn push_to_session(sessions: &SessionMap, session_id: &str, payload: &Value) -> bool {
let json = match serde_json::to_string(payload) {
Ok(s) => s,
Err(_) => return false,
};
let guard = sessions.lock().await;
let Some(session) = guard.get(session_id) else {
return false;
};
session.tx.send(json).await.is_ok()
}
fn uuid_v4_simple() -> String {
uuid::Uuid::new_v4().to_string()
}
mod async_stream {
use super::*;
use std::pin::Pin;
use std::task::{Context, Poll};
pub fn stream_init_event(
init: String,
rx: mpsc::Receiver<String>,
sessions: Arc<SessionMap>,
session_id: String,
) -> McpEventStream {
McpEventStream {
init: Some(init),
rx,
cleanup: Some(SessionCleanup {
sessions,
session_id,
}),
}
}
pub struct McpEventStream {
init: Option<String>,
rx: mpsc::Receiver<String>,
cleanup: Option<SessionCleanup>,
}
struct SessionCleanup {
sessions: Arc<SessionMap>,
session_id: String,
}
impl Drop for McpEventStream {
fn drop(&mut self) {
if let Some(cleanup) = self.cleanup.take() {
tokio::spawn(async move {
let mut guard = cleanup.sessions.lock().await;
guard.remove(&cleanup.session_id);
tracing::debug!(session_id = %cleanup.session_id, "MCP SSE stream closed");
});
}
}
}
impl Stream for McpEventStream {
type Item = Result<Event, Infallible>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if let Some(init) = self.init.take() {
return Poll::Ready(Some(Ok(Event::default().data(init))));
}
match self.rx.poll_recv(cx) {
Poll::Ready(Some(payload)) => Poll::Ready(Some(Ok(Event::default().data(payload)))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
}
async fn handle_mcp_post(
State(state): State<McpState>,
headers: HeaderMap,
body: String,
) -> axum::response::Response {
if let Err(resp) = check_origin(&headers) {
return resp;
}
if let Err(resp) = check_protocol_version(&headers) {
return resp;
}
let req: McpRequest = match serde_json::from_str(&body) {
Ok(req) => req,
Err(e) => {
let resp = json!({
"jsonrpc": "2.0",
"id": Value::Null,
"error": {
"code": E_PARSE,
"message": format!("parse error: {e}"),
},
});
return (StatusCode::OK, Json(resp)).into_response();
}
};
match state.server.handle(req).await {
Some(resp) => match serde_json::to_value(&resp) {
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"jsonrpc": "2.0",
"id": Value::Null,
"error": {
"code": -32603,
"message": format!("response serialization failed: {e}"),
},
})),
)
.into_response(),
},
None => StatusCode::ACCEPTED.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
async fn boot_test_server() -> (SocketAddr, JoinHandle<()>) {
let server = Arc::new(McpServer::new());
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let (bound, task, _sessions) = start_mcp(server, addr).await.expect("start_mcp");
(bound, task)
}
async fn boot_test_server_with_sessions() -> (SocketAddr, JoinHandle<()>, Arc<SessionMap>) {
let server = Arc::new(McpServer::new());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let bound = listener.local_addr().expect("local_addr");
let sessions: Arc<SessionMap> = Arc::new(Mutex::new(HashMap::new()));
let state = McpState {
server,
sessions: sessions.clone(),
};
let app = Router::new()
.route("/mcp", post(handle_mcp_post).get(handle_mcp_get))
.route("/mcp/health", get(handle_health))
.with_state(state);
let task = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(bound, task, sessions)
}
async fn http_post(addr: SocketAddr, body: &str) -> (StatusCode, Value) {
http_post_with_origin(addr, body, None).await
}
async fn http_post_with_origin(
addr: SocketAddr,
body: &str,
origin: Option<&str>,
) -> (StatusCode, Value) {
let (status, text) = http_post_raw(addr, body, origin, None).await;
let value: Value = serde_json::from_str(&text).expect("json");
(status, value)
}
async fn http_post_with_protocol(
addr: SocketAddr,
body: &str,
version: Option<&str>,
) -> (StatusCode, Value) {
let (status, text) = http_post_raw(addr, body, None, version).await;
let value: Value = serde_json::from_str(&text).expect("json");
(status, value)
}
async fn http_post_raw(
addr: SocketAddr,
body: &str,
origin: Option<&str>,
version: Option<&str>,
) -> (StatusCode, String) {
let url = format!("http://{}/mcp", addr);
let client = reqwest::Client::new();
let mut req = client
.post(&url)
.header("Content-Type", "application/json")
.body(body.to_string());
if let Some(origin) = origin {
req = req.header("Origin", origin);
}
if let Some(version) = version {
req = req.header("MCP-Protocol-Version", version);
}
let resp = req.send().await.expect("post");
let status = resp.status();
let text = resp.text().await.expect("body");
(status, text)
}
#[tokio::test]
async fn health_endpoint_returns_ok() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp/health", addr);
let resp = reqwest::get(&url).await.expect("get");
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.expect("json");
assert_eq!(body["status"], "ok");
assert_eq!(body["protocol_version"], car_mcp::PROTOCOL_VERSION);
}
#[tokio::test]
async fn initialize_round_trips_over_http() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let (status, body) = http_post(addr, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["jsonrpc"], "2.0");
assert_eq!(body["id"], 1);
assert_eq!(body["result"]["protocolVersion"], car_mcp::PROTOCOL_VERSION);
}
#[tokio::test]
async fn initialize_negotiates_over_http() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"{}","capabilities":{{}},"clientInfo":{{"name":"c","version":"0"}}}}}}"#,
car_mcp::PROTOCOL_VERSION
);
let (status, body) = http_post(addr, &req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["result"]["protocolVersion"], car_mcp::PROTOCOL_VERSION);
let req = r#"{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}"#;
let (status, body) = http_post(addr, req).await;
assert_eq!(status, StatusCode::OK);
assert!(body.get("error").is_none(), "{body}");
assert_eq!(body["result"]["protocolVersion"], car_mcp::PROTOCOL_VERSION);
}
#[tokio::test]
async fn notification_over_http_returns_202_and_no_body() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let (status, body) = http_post_raw(addr, req, None, None).await;
assert_eq!(status, StatusCode::ACCEPTED);
assert!(body.is_empty(), "expected an empty body, got {body:?}");
}
#[tokio::test]
async fn tools_list_round_trips_over_http() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
let (status, body) = http_post(addr, req).await;
assert_eq!(status, StatusCode::OK);
let tools = body["result"]["tools"].as_array().expect("tools array");
assert_eq!(
tools.len(),
car_mcp::cached_tool_schemas().len(),
"HTTP tools/list must surface every defined tool, and no others"
);
let names: std::collections::HashSet<&str> =
tools.iter().filter_map(|t| t["name"].as_str()).collect();
assert_eq!(
names.len(),
tools.len(),
"tool names must be unique: {tools:?}"
);
}
struct ProbeTool;
#[async_trait::async_trait]
impl car_mcp::ToolHandler for ProbeTool {
async fn call(&self, _args: Value) -> Result<String, car_mcp::ToolError> {
Ok("served by the daemon".to_string())
}
}
#[tokio::test]
async fn a_registered_tool_is_reachable_over_http() {
let mut server = McpServer::new();
server
.register_tool(
json!({
"name": "daemon_probe",
"description": "a daemon-only tool",
"inputSchema": { "type": "object", "properties": {} },
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false,
},
}),
Arc::new(ProbeTool),
)
.expect("registers");
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let (addr, _task, _sessions) = start_mcp(Arc::new(server), addr).await.expect("start_mcp");
tokio::time::sleep(Duration::from_millis(50)).await;
let (status, body) =
http_post(addr, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await;
assert_eq!(status, StatusCode::OK);
let tools = body["result"]["tools"].as_array().expect("tools array");
assert_eq!(tools.len(), car_mcp::cached_tool_schemas().len() + 1);
assert!(tools
.iter()
.any(|t| t["name"].as_str() == Some("daemon_probe")));
let (status, body) = http_post(
addr,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"daemon_probe","arguments":{}}}"#,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["result"]["isError"], false);
assert_eq!(body["result"]["content"][0]["text"], "served by the daemon");
}
#[tokio::test]
async fn malformed_json_returns_parse_error() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let (status, body) = http_post(addr, "{not valid").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["error"]["code"], -32700);
}
#[tokio::test]
async fn shared_memgine_lets_facts_persist_across_requests() {
let memgine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
None,
)));
let server = Arc::new(McpServer::with_memgine(memgine));
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let (addr, _task, _sessions) = start_mcp(server, addr).await.expect("start");
tokio::time::sleep(Duration::from_millis(50)).await;
let add = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"memory_add_fact","arguments":{"subject":"daemon","body":"shared engine works"}}}"#;
let (_, _) = http_post(addr, add).await;
let query = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memory_query","arguments":{"query":"daemon","k":5}}}"#;
let (_, body) = http_post(addr, query).await;
let text = body["result"]["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("daemon"),
"expected query to find ingested fact: {text}"
);
}
#[tokio::test]
async fn sse_get_emits_init_event_and_registers_session() {
let (addr, _task, sessions) = boot_test_server_with_sessions().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("mcp-session-id", "test-session-1")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::OK);
tokio::time::sleep(Duration::from_millis(50)).await;
{
let guard = sessions.lock().await;
assert!(guard.contains_key("test-session-1"));
}
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
let chunk = tokio::time::timeout(Duration::from_secs(2), stream.next())
.await
.expect("timeout")
.expect("chunk")
.expect("bytes");
let body = String::from_utf8_lossy(&chunk).to_string();
assert!(body.contains("notifications/initialized"));
assert!(body.contains("test-session-1"));
}
#[tokio::test]
async fn push_to_session_delivers_payload_to_connected_client() {
let (addr, _task, sessions) = boot_test_server_with_sessions().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("mcp-session-id", "push-session")
.send()
.await
.expect("get");
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
let _init = tokio::time::timeout(Duration::from_secs(2), stream.next())
.await
.expect("timeout")
.expect("chunk")
.expect("bytes");
for _ in 0..20 {
let guard = sessions.lock().await;
if guard.contains_key("push-session") {
break;
}
drop(guard);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let payload = json!({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": { "name": "host_owned_tool", "arguments": {} }
});
let delivered = push_to_session(&sessions, "push-session", &payload).await;
assert!(delivered, "push must succeed for connected session");
let chunk = tokio::time::timeout(Duration::from_secs(2), stream.next())
.await
.expect("timeout")
.expect("chunk")
.expect("bytes");
let body = String::from_utf8_lossy(&chunk).to_string();
assert!(body.contains("host_owned_tool"));
assert!(body.contains("\"id\":99"));
}
#[tokio::test]
async fn push_to_session_returns_false_for_unknown_session() {
let sessions: Arc<SessionMap> = Arc::new(Mutex::new(HashMap::new()));
let delivered = push_to_session(&sessions, "nobody", &json!({"x":1})).await;
assert!(!delivered);
}
#[test]
fn origin_allowed_unit() {
for ok in [
"http://localhost",
"http://localhost:3000",
"https://localhost:8443",
"HTTP://LocalHost:3000",
"http://127.0.0.1",
"http://127.0.0.1:9102",
"https://127.0.0.53",
"http://[::1]",
"http://[::1]:3000",
] {
assert!(origin_allowed(ok), "should be allowed: {ok}");
}
for bad in [
"https://evil.example",
"http://sub.localhost.evil.example",
"http://localhost.evil.com",
"https://127.0.0.1.evil.com",
"null",
"",
"file://",
"file:///etc/passwd",
"ws://localhost:3000",
"localhost:3000",
"http://[::1",
"http://[::1]x",
"http://10.0.0.5",
] {
assert!(!origin_allowed(bad), "should be rejected: {bad}");
}
}
#[tokio::test]
async fn mcp_post_without_origin_is_allowed() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let (status, body) = http_post_with_origin(addr, req, None).await;
assert_eq!(status, StatusCode::OK);
assert!(
body["result"]["tools"].as_array().is_some(),
"absent Origin must be allowed — no real MCP client sends one: {body}"
);
}
#[tokio::test]
async fn mcp_post_with_loopback_origin_is_allowed() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
for origin in [
"http://localhost:3000",
"http://127.0.0.1:9102",
"http://[::1]",
] {
let (status, body) = http_post_with_origin(addr, req, Some(origin)).await;
assert_eq!(status, StatusCode::OK, "origin {origin} must be allowed");
assert!(body["result"]["tools"].as_array().is_some(), "{body}");
}
}
#[tokio::test]
async fn mcp_post_with_foreign_origin_is_rejected() {
let memgine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
None,
)));
let server = Arc::new(McpServer::with_memgine(memgine));
let (addr, _task, _sessions) = start_mcp(server, "127.0.0.1:0".parse().unwrap())
.await
.expect("start");
tokio::time::sleep(Duration::from_millis(50)).await;
let add = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"memory_add_fact","arguments":{"subject":"rebind","body":"attacker planted this"}}}"#;
let (status, body) = http_post_with_origin(addr, add, Some("https://evil.example")).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["error"], "origin not allowed");
let query = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memory_query","arguments":{"query":"rebind","k":5}}}"#;
let (_, body) = http_post(addr, query).await;
let text = body["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
!text.contains("attacker planted this"),
"rejected origin must not reach the tool: {text}"
);
}
#[tokio::test]
async fn mcp_post_with_null_origin_is_rejected() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let (status, body) = http_post_with_origin(addr, req, Some("null")).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"the opaque origin is not a safe origin"
);
assert_eq!(body["error"], "origin not allowed");
}
#[tokio::test]
async fn mcp_get_with_foreign_origin_is_rejected() {
let (addr, _task, sessions) = boot_test_server_with_sessions().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("mcp-session-id", "evil-session")
.header("Origin", "https://evil.example")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
tokio::time::sleep(Duration::from_millis(50)).await;
let guard = sessions.lock().await;
assert!(
!guard.contains_key("evil-session"),
"a rejected GET must not register a session"
);
}
#[tokio::test]
async fn health_stays_reachable_from_any_origin() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp/health", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("Origin", "https://evil.example")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn mcp_post_without_protocol_version_is_allowed() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let (status, body) = http_post_with_protocol(addr, req, None).await;
assert_eq!(status, StatusCode::OK);
assert!(
body["result"]["tools"].as_array().is_some(),
"an absent MCP-Protocol-Version must be allowed: {body}"
);
}
#[tokio::test]
async fn mcp_post_with_supported_protocol_version_is_allowed() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let (status, body) =
http_post_with_protocol(addr, req, Some(car_mcp::PROTOCOL_VERSION)).await;
assert_eq!(status, StatusCode::OK);
assert!(body["result"]["tools"].as_array().is_some(), "{body}");
}
#[tokio::test]
async fn mcp_post_with_unsupported_protocol_version_is_rejected() {
let memgine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
None,
)));
let server = Arc::new(McpServer::with_memgine(memgine));
let (addr, _task, _sessions) = start_mcp(server, "127.0.0.1:0".parse().unwrap())
.await
.expect("start");
tokio::time::sleep(Duration::from_millis(50)).await;
let add = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"memory_add_fact","arguments":{"subject":"dialect","body":"wrong-revision write"}}}"#;
let (status, body) = http_post_with_protocol(addr, add, Some("1999-01-01")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "unsupported MCP-Protocol-Version");
assert_eq!(body["requested"], "1999-01-01");
let supported = body["supported"].as_array().expect("supported list");
assert!(
supported
.iter()
.any(|v| v.as_str() == Some(car_mcp::PROTOCOL_VERSION)),
"the 400 must name what we do speak: {body}"
);
let query = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memory_query","arguments":{"query":"dialect","k":5}}}"#;
let (_, body) = http_post(addr, query).await;
let text = body["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
!text.contains("wrong-revision write"),
"a rejected protocol version must not reach the tool: {text}"
);
}
#[tokio::test]
async fn mcp_get_with_unsupported_protocol_version_is_rejected() {
let (addr, _task, sessions) = boot_test_server_with_sessions().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("mcp-session-id", "stale-dialect-session")
.header("MCP-Protocol-Version", "1999-01-01")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
tokio::time::sleep(Duration::from_millis(50)).await;
let guard = sessions.lock().await;
assert!(
!guard.contains_key("stale-dialect-session"),
"a rejected GET must not register a session"
);
}
#[tokio::test]
async fn health_ignores_protocol_version_header() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp/health", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("MCP-Protocol-Version", "1999-01-01")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.expect("json");
assert_eq!(body["status"], "ok");
assert_eq!(body["protocol_version"], car_mcp::PROTOCOL_VERSION);
}
}