use std::io;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rmcp::model::{CallToolRequestParams, ClientInfo};
use rmcp::service::RunningService;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::{RoleClient, ServiceExt};
use serde_json::{json, Map, Value};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use velesdb_memory::mcp::McpServer;
use velesdb_memory::{DynEmbedder, HashEmbedder, MemoryService, DEFAULT_DIMENSION};
#[derive(Clone, Default)]
struct Capture {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl Capture {
fn clear(&self) {
self.buffer.lock().expect("capture lock").clear();
}
fn text(&self) -> String {
String::from_utf8_lossy(&self.buffer.lock().expect("capture lock")).into_owned()
}
}
struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
impl io::Write for CaptureWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0
.lock()
.expect("capture lock")
.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = CaptureWriter;
fn make_writer(&'a self) -> Self::Writer {
CaptureWriter(Arc::clone(&self.buffer))
}
}
fn capture_for_test() -> (Capture, MutexGuard<'static, ()>) {
static CAPTURE: OnceLock<Capture> = OnceLock::new();
static SEQ: Mutex<()> = Mutex::new(());
let guard = SEQ
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let capture = CAPTURE
.get_or_init(|| {
let capture = Capture::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.with_env_filter(tracing_subscriber::EnvFilter::new(
velesdb_memory::logging::INCIDENT_PRESET,
))
.with_writer(capture.clone())
.finish();
tracing::subscriber::set_global_default(subscriber)
.expect("install the capturing subscriber once per process");
capture
})
.clone();
capture.clear();
(capture, guard)
}
struct TestServer {
addr: SocketAddr,
handle: JoinHandle<()>,
ct: CancellationToken,
_store_dir: tempfile::TempDir,
}
async fn spawn_server() -> TestServer {
spawn_server_with_keep_alive(velesdb_memory::http::DEFAULT_HTTP_KEEP_ALIVE).await
}
async fn spawn_server_with_keep_alive(keep_alive: std::time::Duration) -> TestServer {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let embedder: DynEmbedder = Box::new(HashEmbedder::new(DEFAULT_DIMENSION));
let service =
MemoryService::open(store_dir.path(), embedder).expect("open scratch memory store");
let server = McpServer::new(service);
let ct = CancellationToken::new();
let app = velesdb_memory::http::router_with_limits_and_keep_alive(
server,
ct.child_token(),
velesdb_memory::http::DEFAULT_HTTP_MAX_BODY_BYTES,
velesdb_memory::http::DEFAULT_HTTP_MAX_SESSIONS,
Some(keep_alive),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("read bound local addr");
let shutdown_ct = ct.clone();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move { shutdown_ct.cancelled_owned().await })
.await;
});
TestServer {
addr,
handle,
ct,
_store_dir: store_dir,
}
}
async fn shutdown(server: TestServer) {
server.ct.cancel();
server
.handle
.await
.expect("http server task must not panic");
}
async fn connect(addr: SocketAddr) -> RunningService<RoleClient, ClientInfo> {
let transport = StreamableHttpClientTransport::from_config(
StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")),
);
ClientInfo::default()
.serve(transport)
.await
.expect("MCP initialize handshake over HTTP")
}
fn as_args(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map,
other => panic!("expected a JSON object, got {other:?}"),
}
}
#[test]
fn a_handled_tool_call_leaves_tool_verdict_and_duration() {
let (capture, _seq) = capture_for_test();
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
rt.block_on(async {
let server = spawn_server().await;
let client = connect(server.addr).await;
client
.call_tool(
CallToolRequestParams::new("recall")
.with_arguments(as_args(json!({ "query": "anything", "limit": 3 }))),
)
.await
.expect("recall call over HTTP");
client.cancel().await.expect("close the MCP client");
shutdown(server).await;
});
let log = capture.text();
assert!(
log.contains("tool=recall"),
"a handled call must leave an event naming the tool — got:\n{log}"
);
assert!(
log.contains("verdict=ok"),
"the event must carry the call's verdict — got:\n{log}"
);
assert!(
log.contains("elapsed_ms="),
"the event must carry the call's duration — got:\n{log}"
);
}
#[test]
fn an_unknown_session_leaves_a_404_transport_event() {
let (capture, _seq) = capture_for_test();
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
let (status, log) = rt.block_on(async {
let server = spawn_server().await;
let client = connect(server.addr).await;
client.cancel().await.expect("close the MCP client");
let response = reqwest::Client::new()
.post(format!("http://{}/mcp", server.addr))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("mcp-session-id", "no-such-session")
.body(
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "recall", "arguments": { "query": "x" } }
})
.to_string(),
)
.send()
.await
.expect("POST to /mcp with an unknown session id");
let status = response.status().as_u16();
shutdown(server).await;
(status, capture.text())
});
assert_eq!(
status, 404,
"an unknown session id must be refused with 404"
);
assert!(
log.contains("status=200") || log.contains("status=202"),
"the handshake's transport events are the positive control — got:\n{log}"
);
assert!(
log.contains("status=404"),
"the refusal must leave a transport event carrying its 404 — got:\n{log}"
);
assert!(
log.contains("session=no-such-session"),
"the refused event must name the session that was refused — got:\n{log}"
);
}
#[test]
fn events_never_carry_fact_content() {
const CANARY: &str = "CANARY-9f3a1c-le-contenu-ne-doit-jamais-fuiter";
let (capture, _seq) = capture_for_test();
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
rt.block_on(async {
let server = spawn_server().await;
let client = connect(server.addr).await;
client
.call_tool(
CallToolRequestParams::new("remember")
.with_arguments(as_args(json!({ "fact": CANARY }))),
)
.await
.expect("remember call over HTTP");
client.cancel().await.expect("close the MCP client");
shutdown(server).await;
});
let log = capture.text();
assert!(
log.contains("tool=remember"),
"the call must be traced (positive control for the canary check) — got:\n{log}"
);
assert!(
!log.contains(CANARY),
"an event carried fact content — the issue's privacy line forbids \
payloads in traces:\n{log}"
);
}
#[test]
fn error_responses_never_carry_client_content() {
const FIELD_CANARY: &str = "CANARY-FIELD-7d2b1e not a valid identifier";
const VALUE_CANARY: &str = "CANARY-VALUE-4c9a0f-le-contenu-du-client";
let (capture, _seq) = capture_for_test();
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
rt.block_on(async {
let server = spawn_server().await;
let client = connect(server.addr).await;
for entry in [
json!({ "field": FIELD_CANARY, "op": "eq", "value": "x" }),
json!({ "field": "okfield", "op": "eq", "value": { "secret": VALUE_CANARY } }),
] {
let refused = client
.call_tool(
CallToolRequestParams::new("recall_where").with_arguments(as_args(
json!({ "query": "anything", "filters": [entry] }),
)),
)
.await;
assert!(
refused.is_err() || refused.is_ok_and(|r| r.is_error == Some(true)),
"the invalid filter must be refused — a filter silently \
accepted means this test stopped exercising the error path"
);
}
client.cancel().await.expect("close the MCP client");
shutdown(server).await;
});
let log = capture.text();
assert!(
log.contains("tool=recall_where") && log.contains("verdict=error"),
"the refused calls must be traced with their verdict (positive \
control) — got:\n{log}"
);
assert!(
!log.contains("CANARY-FIELD") && !log.contains("CANARY-VALUE"),
"an error event carried client content under the incident preset — \
the preset must exclude rmcp's response-error dump:\n{log}"
);
}
#[test]
fn an_idle_session_leaves_the_worker_quit_signal() {
let (capture, _seq) = capture_for_test();
let rt = tokio::runtime::Runtime::new().expect("build tokio runtime");
rt.block_on(async {
let server = spawn_server_with_keep_alive(std::time::Duration::from_millis(150)).await;
let client = connect(server.addr).await;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let log = capture.text();
if log.contains("worker quit with reason") && log.contains("IdleTimeout") {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the idle retirement must be visible under the incident \
preset within 10s — got:\n{log}"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
drop(client);
shutdown(server).await;
});
}