use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::State,
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use tokio::task::JoinHandle;
use car_mcp::error_codes::PARSE as E_PARSE;
use car_mcp::{Request as McpRequest, Server as McpServer};
const PROTOCOL_HEADER: &str = "mcp-protocol-version";
const SESSION_HEADER: &str = "mcp-session-id";
const BATCH_MODE_QUERY: &str = "car-mcp-mode=batch";
tokio::task_local! {
static MCP_PEER_PRINCIPAL: Option<String>;
}
pub(crate) fn current_mcp_peer_principal() -> Option<String> {
MCP_PEER_PRINCIPAL.try_with(Clone::clone).ok().flatten()
}
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())
}
#[derive(Clone)]
struct McpState {
server: Arc<McpServer>,
peer_state: Option<Arc<crate::ServerState>>,
}
pub async fn start_mcp(
server: Arc<McpServer>,
addr: SocketAddr,
) -> Result<(SocketAddr, JoinHandle<()>), String> {
start_mcp_inner(server, addr, None).await
}
pub async fn start_mcp_with_peer_sessions(
server: Arc<McpServer>,
addr: SocketAddr,
peer_state: Arc<crate::ServerState>,
) -> Result<(SocketAddr, JoinHandle<()>), String> {
start_mcp_inner(server, addr, Some(peer_state)).await
}
async fn start_mcp_inner(
server: Arc<McpServer>,
addr: SocketAddr,
peer_state: Option<Arc<crate::ServerState>>,
) -> Result<(SocketAddr, JoinHandle<()>), 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 state = McpState { server, peer_state };
let app: Router = Router::new()
.route(
"/mcp",
post(handle_mcp_post)
.get(handle_mcp_get)
.delete(handle_mcp_delete),
)
.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))
}
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() -> impl IntoResponse {
(
StatusCode::METHOD_NOT_ALLOWED,
[(axum::http::header::ALLOW, "POST, DELETE")],
Json(json!({
"error": "this server offers no SSE stream; POST JSON-RPC requests to /mcp",
})),
)
}
async fn handle_mcp_post(
State(state): State<McpState>,
headers: HeaderMap,
uri: Uri,
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();
}
};
let is_initialize = req.method == "initialize";
let mut minted_session_id = None;
let principal = if let Some(peer_state) = state.peer_state.as_ref() {
if is_initialize {
let receive_capable = uri
.query()
.is_none_or(|query| !query.split('&').any(|part| part == BATCH_MODE_QUERY));
let (session_id, principal) =
crate::peers::open_mcp_peer_session(peer_state, receive_capable).await;
minted_session_id = Some(session_id);
Some(principal)
} else if let Some(session_id) = headers
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
{
match crate::peers::touch_mcp_peer_session(peer_state, session_id).await {
Some(principal) => Some(principal),
None => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": "unknown or expired MCP session" })),
)
.into_response();
}
}
} else {
None
}
} else {
None
};
let handled = MCP_PEER_PRINCIPAL
.scope(
principal,
crate::mcp_daemon::handle_request(&state.server, req, &headers),
)
.await;
let mut response = match handled {
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(),
};
if let Some(session_id) = minted_session_id {
if let Ok(value) = session_id.parse() {
response.headers_mut().insert(SESSION_HEADER, value);
}
}
response
}
async fn handle_mcp_delete(
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 Some(peer_state) = state.peer_state.as_ref() else {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
};
let Some(session_id) = headers
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "missing MCP-Session-Id" })),
)
.into_response();
};
if crate::peers::close_mcp_peer_session(peer_state, session_id).await {
StatusCode::NO_CONTENT.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "unknown or expired MCP session" })),
)
.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) = start_mcp(server, addr).await.expect("start_mcp");
(bound, task)
}
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)
}
async fn initialize_peer_session(client: &reqwest::Client, url: &str) -> (String, Value) {
let response = client
.post(url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let session_id = response
.headers()
.get(SESSION_HEADER)
.and_then(|value| value.to_str().ok())
.expect("initialize minted MCP-Session-Id")
.to_string();
let body = response.json().await.unwrap();
(session_id, body)
}
async fn call_peer_tool(
client: &reqwest::Client,
url: &str,
session_id: &str,
name: &str,
arguments: Value,
) -> Value {
client
.post(url)
.header(SESSION_HEADER, session_id)
.json(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": name, "arguments": arguments }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
#[tokio::test]
async fn concurrent_mcp_sessions_send_as_distinct_principals() {
let temp = tempfile::tempdir().unwrap();
let state = Arc::new(crate::ServerState::standalone(temp.path().into()));
state
.attached_agents
.lock()
.await
.insert("milo".into(), "detached-test-client".into());
let mut server = McpServer::new();
crate::peers::register_peer_tools(&mut server, state.clone()).unwrap();
let (addr, task) = start_mcp_with_peer_sessions(
Arc::new(server),
"127.0.0.1:0".parse().unwrap(),
state.clone(),
)
.await
.unwrap();
let client = reqwest::Client::new();
let url = format!("http://{addr}/mcp");
let (first, second) = tokio::join!(
initialize_peer_session(&client, &url),
initialize_peer_session(&client, &url)
);
assert_ne!(first.0, second.0, "each initialize mints a session");
let (sent_first, sent_second) = tokio::join!(
call_peer_tool(
&client,
&url,
&first.0,
"peer_message",
json!({"to":"milo", "body":"same body"})
),
call_peer_tool(
&client,
&url,
&second.0,
"peer_message",
json!({"to":"milo", "body":"same body"})
)
);
assert_eq!(sent_first["result"]["isError"], true, "{sent_first}");
assert_eq!(sent_second["result"]["isError"], true, "{sent_second}");
let audit = std::fs::read_to_string(&state.peer_audit_journal).unwrap();
let senders: std::collections::HashSet<String> = audit
.lines()
.map(|line| {
serde_json::from_str::<Value>(line).unwrap()["from"]
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(senders.len(), 2, "audit rows: {audit}");
assert!(senders.iter().all(|sender| sender.starts_with("mcp:")));
assert!(!senders.contains("mcp:external-cli"));
task.abort();
}
#[tokio::test]
async fn batch_session_is_send_only_and_delete_ends_a_live_session() {
let temp = tempfile::tempdir().unwrap();
let state = Arc::new(crate::ServerState::standalone(temp.path().into()));
let mut server = McpServer::new();
crate::peers::register_peer_tools(&mut server, state.clone()).unwrap();
let (addr, task) = start_mcp_with_peer_sessions(
Arc::new(server),
"127.0.0.1:0".parse().unwrap(),
state.clone(),
)
.await
.unwrap();
let client = reqwest::Client::new();
let base = format!("http://{addr}/mcp");
let (batch_id, _) =
initialize_peer_session(&client, &format!("{base}?{BATCH_MODE_QUERY}")).await;
let inbox = call_peer_tool(&client, &base, &batch_id, "peer_inbox", json!({})).await;
assert_eq!(inbox["result"]["isError"], true, "{inbox}");
assert!(inbox["result"]["content"][0]["text"]
.as_str()
.is_some_and(|text| text.contains("send-only")));
assert!(crate::peers::snapshot_mcp_sessions(&state).await.is_empty());
let (live_id, _) = initialize_peer_session(&client, &base).await;
assert_eq!(crate::peers::snapshot_mcp_sessions(&state).await.len(), 1);
let deleted = client
.delete(&base)
.header(SESSION_HEADER, &live_id)
.send()
.await
.unwrap();
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
assert!(crate::peers::snapshot_mcp_sessions(&state).await.is_empty());
task.abort();
}
#[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) = 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) = 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 get_mcp_returns_405_method_not_allowed() {
let (addr, _task) = boot_test_server().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", "hopeful-subscriber")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers()
.get(axum::http::header::ALLOW)
.and_then(|v| v.to_str().ok()),
Some("POST, DELETE"),
"a 405 names what IS allowed"
);
let body: Value = resp.json().await.expect("json body");
assert!(
body["error"]
.as_str()
.is_some_and(|e| e.contains("no SSE stream")),
"the body should say why: {body}"
);
}
#[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) = 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_gets_the_same_405() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("Origin", "https://evil.example")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[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) = 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_gets_the_same_405() {
let (addr, _task) = boot_test_server().await;
tokio::time::sleep(Duration::from_millis(50)).await;
let url = format!("http://{}/mcp", addr);
let resp = reqwest::Client::new()
.get(&url)
.header("MCP-Protocol-Version", "1999-01-01")
.send()
.await
.expect("get");
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
}
#[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);
}
}