Files
openbrain-mcp/tests/e2e_mcp.rs
2026-03-24 03:20:10 +00:00

1247 lines
35 KiB
Rust

use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::time::Duration;
use tokio_postgres::NoTls;
use uuid::Uuid;
fn base_url() -> String {
std::env::var("OPENBRAIN_E2E_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:3100".to_string())
}
fn remote_mode() -> bool {
std::env::var("OPENBRAIN_E2E_REMOTE")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
fn api_key() -> Option<String> {
std::env::var("OPENBRAIN_E2E_API_KEY").ok()
.or_else(|| std::env::var("OPENBRAIN__AUTH__API_KEYS").ok())
.map(|keys| keys.split(',').next().unwrap_or("").trim().to_string())
.filter(|k| !k.is_empty())
}
fn db_url() -> String {
let host = std::env::var("OPENBRAIN__DATABASE__HOST").unwrap_or_else(|_| "localhost".to_string());
let port = std::env::var("OPENBRAIN__DATABASE__PORT").unwrap_or_else(|_| "5432".to_string());
let name = std::env::var("OPENBRAIN__DATABASE__NAME").unwrap_or_else(|_| "openbrain".to_string());
let user = std::env::var("OPENBRAIN__DATABASE__USER").unwrap_or_else(|_| "openbrain_svc".to_string());
let password = std::env::var("OPENBRAIN__DATABASE__PASSWORD")
.unwrap_or_else(|_| "your_secure_password_here".to_string());
format!("host={host} port={port} dbname={name} user={user} password={password}")
}
async fn ensure_schema() {
if remote_mode() {
return;
}
let (client, connection) = tokio_postgres::connect(&db_url(), NoTls)
.await
.expect("connect to postgres for e2e schema setup");
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("postgres connection error: {e}");
}
});
let vector_exists = client
.query_one("SELECT to_regtype('vector')::text", &[])
.await
.expect("query vector type availability")
.get::<_, Option<String>>(0)
.is_some();
if !vector_exists {
if let Err(e) = client.execute("CREATE EXTENSION IF NOT EXISTS vector", &[]).await {
panic!(
"pgvector extension is not available for this PostgreSQL instance: {e}. \
Install pgvector for your active PostgreSQL major version, then run: CREATE EXTENSION vector;"
);
}
}
client
.batch_execute(
r#"
CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
embedding vector(384) NOT NULL,
keywords TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
ALTER TABLE memories ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
ALTER TABLE memories ADD COLUMN IF NOT EXISTS tsv tsvector;
CREATE OR REPLACE FUNCTION memories_tsv_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.tsv :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'A') ||
setweight(
to_tsvector('pg_catalog.english', COALESCE(array_to_string(NEW.keywords, ' '), '')),
'B'
);
RETURN NEW;
END;
$$;
UPDATE memories
SET tsv =
setweight(to_tsvector('pg_catalog.english', COALESCE(content, '')), 'A') ||
setweight(
to_tsvector('pg_catalog.english', COALESCE(array_to_string(keywords, ' '), '')),
'B'
)
WHERE tsv IS NULL;
DROP TRIGGER IF EXISTS memories_tsv_update ON memories;
CREATE TRIGGER memories_tsv_update
BEFORE INSERT OR UPDATE OF content, keywords ON memories
FOR EACH ROW
EXECUTE FUNCTION memories_tsv_trigger();
CREATE INDEX IF NOT EXISTS idx_memories_agent ON memories(agent_id);
CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_memories_tsv ON memories
USING GIN (tsv);
CREATE INDEX IF NOT EXISTS idx_memories_expires_at ON memories (expires_at)
WHERE expires_at IS NOT NULL;
"#,
)
.await
.expect("create memories table/indexes for e2e");
}
async fn wait_until_ready(client: &reqwest::Client, base: &str) {
for _ in 0..60 {
let resp = client.get(format!("{base}/ready")).send().await;
if let Ok(resp) = resp {
if resp.status().is_success() {
let body: Value = resp.json().await.expect("/ready JSON response");
if body.get("status").and_then(Value::as_str) == Some("ready") {
return;
}
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
panic!("Server did not become ready at {base}/ready within timeout");
}
async fn call_jsonrpc(client: &reqwest::Client, base: &str, request: Value) -> Value {
let mut req_builder = client
.post(format!("{base}/mcp/message"))
.json(&request);
// Add API key header if available
if let Some(key) = api_key() {
req_builder = req_builder.header("X-API-Key", key);
}
req_builder
.send()
.await
.expect("JSON-RPC HTTP request")
.json()
.await
.expect("JSON-RPC response body")
}
async fn call_streamable_jsonrpc(
client: &reqwest::Client,
base: &str,
request: Value,
) -> reqwest::Response {
let mut req_builder = client
.post(format!("{base}/mcp"))
.header("Accept", "application/json, text/event-stream")
.json(&request);
if let Some(key) = api_key() {
req_builder = req_builder.header("X-API-Key", key);
}
req_builder
.send()
.await
.expect("streamable JSON-RPC HTTP request")
}
/// Make an authenticated GET request to an MCP endpoint
async fn get_mcp_endpoint(client: &reqwest::Client, base: &str, path: &str) -> reqwest::Response {
let mut req_builder = client.get(format!("{base}{path}"));
if let Some(key) = api_key() {
req_builder = req_builder.header("X-API-Key", key);
}
req_builder.send().await.expect(&format!("GET {path}"))
}
async fn read_sse_event(
response: &mut reqwest::Response,
buffer: &mut String,
) -> Option<(Option<String>, String)> {
loop {
*buffer = buffer.replace("\r\n", "\n");
if let Some(idx) = buffer.find("\n\n") {
let raw_event = buffer[..idx].to_string();
*buffer = buffer[idx + 2..].to_string();
let mut event_type = None;
let mut data_lines = Vec::new();
for line in raw_event.lines() {
if let Some(value) = line.strip_prefix("event:") {
event_type = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim_start().to_string());
}
}
return Some((event_type, data_lines.join("\n")));
}
let chunk = response
.chunk()
.await
.expect("read SSE chunk")?;
buffer.push_str(std::str::from_utf8(&chunk).expect("SSE chunk should be valid UTF-8"));
}
}
async fn call_tool(
client: &reqwest::Client,
base: &str,
tool_name: &str,
arguments: Value,
) -> Value {
let request = json!({
"jsonrpc": "2.0",
"id": Uuid::new_v4().to_string(),
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
});
let response = call_jsonrpc(client, base, request).await;
if let Some(error) = response.get("error") {
panic!("tools/call for '{tool_name}' failed: {error}");
}
let text_payload = response
.get("result")
.and_then(|r| r.get("content"))
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|item| item.get("text"))
.and_then(Value::as_str)
.expect("result.content[0].text payload");
serde_json::from_str(text_payload).expect("tool text payload to be valid JSON")
}
#[tokio::test]
async fn e2e_store_query_purge_roundtrip() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
ensure_schema().await;
wait_until_ready(&client, &base).await;
let agent_id = format!("e2e-agent-{}", Uuid::new_v4());
let memory_text = format!(
"E2E memory {}: user prefers dark theme and vim bindings",
Uuid::new_v4()
);
// Ensure clean slate for this test agent.
let _ = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent_id, "confirm": true }),
)
.await;
let store_result = call_tool(
&client,
&base,
"store",
json!({
"agent_id": agent_id,
"content": memory_text,
"metadata": { "source": "e2e-test", "suite": "store-query-purge" }
}),
)
.await;
assert_eq!(
store_result.get("success").and_then(Value::as_bool),
Some(true),
"store should succeed"
);
let query_result = call_tool(
&client,
&base,
"query",
json!({
"agent_id": agent_id,
"query": "What are the user's editor preferences?",
"limit": 5,
"threshold": 0.0
}),
)
.await;
let count = query_result
.get("count")
.and_then(Value::as_u64)
.expect("query.count");
assert!(count >= 1, "query should return at least one stored memory");
let results = query_result
.get("results")
.and_then(Value::as_array)
.expect("query.results");
let found_stored_content = results.iter().any(|item| {
item.get("content")
.and_then(Value::as_str)
.map(|content| content == memory_text)
.unwrap_or(false)
});
assert!(
found_stored_content,
"query results should include the content stored by this test"
);
let purge_result = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent_id, "confirm": true }),
)
.await;
let deleted = purge_result
.get("deleted")
.and_then(Value::as_u64)
.expect("purge.deleted");
assert!(deleted >= 1, "purge should delete at least one memory");
let query_after_purge = call_tool(
&client,
&base,
"query",
json!({
"agent_id": agent_id,
"query": "dark theme vim bindings",
"limit": 5,
"threshold": 0.0
}),
)
.await;
assert_eq!(
query_after_purge.get("count").and_then(Value::as_u64),
Some(0),
"query after purge should return no memories for this agent"
);
}
#[tokio::test]
async fn e2e_transport_tools_list_and_unknown_method() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let list_response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "tools-list-1",
"method": "tools/list",
"params": {}
}),
)
.await;
let tools = list_response
.get("result")
.and_then(|r| r.get("tools"))
.and_then(Value::as_array)
.expect("tools/list result.tools");
let tool_names: Vec<&str> = tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(tool_names.contains(&"store"), "tools/list should include store");
assert!(tool_names.contains(&"query"), "tools/list should include query");
assert!(tool_names.contains(&"purge"), "tools/list should include purge");
let unknown_response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "unknown-1",
"method": "not/a/real/method",
"params": {}
}),
)
.await;
assert_eq!(
unknown_response
.get("error")
.and_then(|e| e.get("code"))
.and_then(Value::as_i64),
Some(-32601),
"unknown method should return Method Not Found"
);
}
#[tokio::test]
async fn e2e_streamable_initialize_and_tools_list() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let initialize_response: Value = call_streamable_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "streamable-init-1",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "e2e-client",
"version": "0.1.0"
}
}
}),
)
.await
.json()
.await
.expect("streamable initialize JSON");
assert_eq!(
initialize_response
.get("result")
.and_then(|value| value.get("protocolVersion"))
.and_then(Value::as_str),
Some("2024-11-05")
);
let tools_list_response: Value = call_streamable_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "streamable-tools-list-1",
"method": "tools/list",
"params": {}
}),
)
.await
.json()
.await
.expect("streamable tools/list JSON");
assert!(
tools_list_response
.get("result")
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
.map(|tools| !tools.is_empty())
.unwrap_or(false),
"streamable /mcp tools/list should return tool definitions"
);
}
#[tokio::test]
async fn e2e_streamable_get_returns_405() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let mut request = client
.get(format!("{base}/mcp"))
.header("Accept", "text/event-stream");
if let Some(key) = api_key() {
request = request.header("X-API-Key", key);
}
let response = request.send().await.expect("GET /mcp");
assert_eq!(
response.status(),
reqwest::StatusCode::METHOD_NOT_ALLOWED,
"streamable GET /mcp should explicitly return 405 when standalone SSE streams are not offered"
);
}
#[tokio::test]
async fn e2e_purge_requires_confirm_flag() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "purge-confirm-1",
"method": "tools/call",
"params": {
"name": "purge",
"arguments": {
"agent_id": format!("e2e-agent-{}", Uuid::new_v4()),
"confirm": false
}
}
}),
)
.await;
let error_message = response
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.expect("purge without confirm should return JSON-RPC error");
assert!(
error_message.contains("confirm: true") || error_message.contains("confirm"),
"purge error should explain confirmation requirement"
);
}
#[tokio::test]
async fn e2e_query_isolated_by_agent_id() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
ensure_schema().await;
wait_until_ready(&client, &base).await;
let agent_a = format!("e2e-agent-a-{}", Uuid::new_v4());
let agent_b = format!("e2e-agent-b-{}", Uuid::new_v4());
let a_text = format!("A {} prefers dark mode", Uuid::new_v4());
let b_text = format!("B {} prefers light mode", Uuid::new_v4());
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent_a, "confirm": true })).await;
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent_b, "confirm": true })).await;
let _ = call_tool(
&client,
&base,
"store",
json!({ "agent_id": agent_a, "content": a_text, "metadata": {"suite": "agent-isolation"} }),
)
.await;
let _ = call_tool(
&client,
&base,
"store",
json!({ "agent_id": agent_b, "content": b_text, "metadata": {"suite": "agent-isolation"} }),
)
.await;
let query_a = call_tool(
&client,
&base,
"query",
json!({
"agent_id": agent_a,
"query": "mode preference",
"limit": 10,
"threshold": 0.0
}),
)
.await;
let results = query_a
.get("results")
.and_then(Value::as_array)
.expect("query results");
let has_a = results.iter().any(|item| {
item.get("content")
.and_then(Value::as_str)
.map(|s| s == a_text)
.unwrap_or(false)
});
let has_b = results.iter().any(|item| {
item.get("content")
.and_then(Value::as_str)
.map(|s| s == b_text)
.unwrap_or(false)
});
assert!(has_a, "agent A query should include agent A memory");
assert!(!has_b, "agent A query must not include agent B memory");
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent_a, "confirm": true })).await;
let _ = call_tool(&client, &base, "purge", json!({ "agent_id": agent_b, "confirm": true })).await;
}
#[tokio::test]
async fn e2e_initialize_contract() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {}
}),
)
.await;
let result = response.get("result").expect("initialize result");
assert_eq!(
result.get("protocolVersion").and_then(Value::as_str),
Some("2024-11-05")
);
assert_eq!(
result
.get("serverInfo")
.and_then(|v| v.get("name"))
.and_then(Value::as_str),
Some("openbrain-mcp")
);
}
#[tokio::test]
async fn e2e_initialized_notification_is_accepted() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let mut request = client
.post(format!("{base}/mcp/message"))
.json(&json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
}));
if let Some(key) = api_key() {
request = request.header("X-API-Key", key);
}
let response = request.send().await.expect("initialized notification request");
assert_eq!(
response.status(),
reqwest::StatusCode::ACCEPTED,
"notifications/initialized should be accepted without a JSON-RPC response body"
);
}
#[tokio::test]
async fn e2e_sse_session_routes_posted_response() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let mut sse_request = client
.get(format!("{base}/mcp/sse"))
.header("Accept", "text/event-stream");
if let Some(key) = api_key() {
sse_request = sse_request.header("X-API-Key", key);
}
let mut sse_response = sse_request.send().await.expect("GET /mcp/sse");
assert_eq!(sse_response.status(), reqwest::StatusCode::OK);
assert!(
sse_response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.starts_with("text/event-stream"))
.unwrap_or(false),
"SSE endpoint should return text/event-stream"
);
let mut buffer = String::new();
let (event_type, endpoint) = tokio::time::timeout(
Duration::from_secs(10),
read_sse_event(&mut sse_response, &mut buffer),
)
.await
.expect("timed out waiting for SSE endpoint event")
.expect("SSE endpoint event");
assert_eq!(event_type.as_deref(), Some("endpoint"));
assert!(
endpoint.contains("/mcp/message?sessionId="),
"endpoint event should advertise a session-specific message URL"
);
let post_url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
endpoint
} else {
format!("{base}{endpoint}")
};
let mut post_request = client
.post(post_url)
.json(&json!({
"jsonrpc": "2.0",
"id": "sse-tools-list-1",
"method": "tools/list",
"params": {}
}));
if let Some(key) = api_key() {
post_request = post_request.header("X-API-Key", key);
}
let post_response = post_request.send().await.expect("POST session message");
assert_eq!(
post_response.status(),
reqwest::StatusCode::ACCEPTED,
"session-bound POST should be accepted and routed over SSE"
);
let (event_type, payload) = tokio::time::timeout(
Duration::from_secs(10),
read_sse_event(&mut sse_response, &mut buffer),
)
.await
.expect("timed out waiting for SSE message event")
.expect("SSE message event");
assert_eq!(event_type.as_deref(), Some("message"));
let message: Value = serde_json::from_str(&payload).expect("SSE payload should be valid JSON");
assert_eq!(
message.get("id").and_then(Value::as_str),
Some("sse-tools-list-1")
);
assert!(
message
.get("result")
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
.map(|tools| !tools.is_empty())
.unwrap_or(false),
"SSE-routed tools/list response should include tool definitions"
);
}
#[tokio::test]
async fn e2e_health_endpoints() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
// Root health endpoint - no auth required
let root_health: Value = client
.get(format!("{base}/health"))
.send()
.await
.expect("GET /health")
.json()
.await
.expect("/health JSON");
assert_eq!(
root_health.get("status").and_then(Value::as_str),
Some("ok"),
"/health should report server liveness"
);
// MCP health endpoint - requires auth if enabled
let mcp_health: Value = get_mcp_endpoint(&client, &base, "/mcp/health")
.await
.json()
.await
.expect("/mcp/health JSON");
assert_eq!(
mcp_health.get("status").and_then(Value::as_str),
Some("healthy"),
"/mcp/health should report MCP transport health"
);
assert_eq!(
mcp_health.get("server").and_then(Value::as_str),
Some("openbrain-mcp")
);
}
#[tokio::test]
async fn e2e_store_requires_content() {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "store-missing-content-1",
"method": "tools/call",
"params": {
"name": "store",
"arguments": {
"agent_id": format!("e2e-agent-{}", Uuid::new_v4()),
"metadata": {"suite": "validation"}
}
}
}),
)
.await;
let message = response
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.expect("store missing content should return an error message");
assert!(
message.contains("Missing required parameter: content"),
"store validation should mention missing content"
);
}
#[tokio::test]
async fn e2e_auth_rejection_without_key() {
// This test only runs when auth is expected to be enabled
let auth_enabled = std::env::var("OPENBRAIN__AUTH__ENABLED")
.map(|v| v == "true")
.unwrap_or(false);
if !auth_enabled {
println!("Skipping auth rejection test - OPENBRAIN__AUTH__ENABLED is not true");
return;
}
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
// Make request WITHOUT API key
let response = client
.post(format!("{base}/mcp/message"))
.json(&json!({
"jsonrpc": "2.0",
"id": "auth-test-1",
"method": "tools/list",
"params": {}
}))
.send()
.await
.expect("HTTP request");
assert_eq!(
response.status().as_u16(),
401,
"Request without API key should return 401 Unauthorized"
);
}
fn pick_free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral port")
.local_addr()
.expect("local addr")
.port()
}
async fn wait_for_status(url: &str, expected_status: reqwest::StatusCode) {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("reqwest client");
for _ in 0..80 {
if let Ok(resp) = client.get(url).send().await {
if resp.status() == expected_status {
return;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
panic!("Timed out waiting for {url} to return status {expected_status}");
}
#[tokio::test]
async fn e2e_auth_enabled_accepts_test_key() {
if remote_mode() {
println!("Skipping local auth spawn test in OPENBRAIN_E2E_REMOTE mode");
return;
}
ensure_schema().await;
let port = pick_free_port();
let base = format!("http://127.0.0.1:{port}");
let test_key = "e2e-test-key-123";
let mut server = Command::new(env!("CARGO_BIN_EXE_openbrain-mcp"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env("OPENBRAIN__SERVER__PORT", port.to_string())
.env("OPENBRAIN__AUTH__ENABLED", "true")
.env("OPENBRAIN__AUTH__API_KEYS", test_key)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn openbrain-mcp for auth-enabled e2e test");
wait_for_status(&format!("{base}/ready"), reqwest::StatusCode::OK).await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
let request = json!({
"jsonrpc": "2.0",
"id": "auth-enabled-1",
"method": "tools/list",
"params": {}
});
let unauthorized = client
.post(format!("{base}/mcp/message"))
.json(&request)
.send()
.await
.expect("unauthorized request");
assert_eq!(
unauthorized.status(),
reqwest::StatusCode::UNAUTHORIZED,
"request without key should be rejected when auth is enabled"
);
let authorized: Value = client
.post(format!("{base}/mcp/message"))
.header("X-API-Key", test_key)
.json(&request)
.send()
.await
.expect("authorized request")
.json()
.await
.expect("authorized JSON response");
assert!(authorized.get("error").is_none(), "valid key should not return JSON-RPC error");
assert!(
authorized
.get("result")
.and_then(|r| r.get("tools"))
.and_then(Value::as_array)
.map(|tools| !tools.is_empty())
.unwrap_or(false),
"authorized tools/list should return tool definitions"
);
let bearer_authorized: Value = client
.post(format!("{base}/mcp/message"))
.header("Authorization", format!("Bearer {test_key}"))
.json(&request)
.send()
.await
.expect("bearer-authorized request")
.json()
.await
.expect("bearer-authorized JSON response");
assert!(
bearer_authorized.get("error").is_none(),
"valid bearer token should not return JSON-RPC error"
);
assert!(
bearer_authorized
.get("result")
.and_then(|r| r.get("tools"))
.and_then(Value::as_array)
.map(|tools| !tools.is_empty())
.unwrap_or(false),
"authorized bearer tools/list should return tool definitions"
);
let _ = server.kill();
let _ = server.wait();
}
// =============================================================================
// Batch Store Tests (Issue #12)
// =============================================================================
#[tokio::test]
async fn e2e_batch_store_basic() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
ensure_schema().await;
wait_until_ready(&client, &base).await;
let agent = format!("batch_{}", uuid::Uuid::new_v4());
let _ = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent.clone(), "confirm": true }),
)
.await;
let result = call_tool(&client, &base, "batch_store", serde_json::json!({
"agent_id": agent.clone(),
"entries": [
{"content": "Fact alpha for batch test"},
{"content": "Fact beta for batch test"},
{"content": "Fact gamma for batch test"}
]
})).await;
let _ = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent, "confirm": true }),
)
.await;
assert!(result["success"].as_bool().unwrap_or(false));
assert_eq!(result["count"].as_i64().unwrap_or(0), 3);
Ok(())
}
#[tokio::test]
async fn e2e_batch_store_empty_rejected() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "batch-empty-1",
"method": "tools/call",
"params": {
"name": "batch_store",
"arguments": {
"entries": []
}
}
}),
)
.await;
assert!(response.get("error").is_some(), "empty batch_store should return an error");
Ok(())
}
#[tokio::test]
async fn e2e_batch_store_exceeds_max() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let entries: Vec<Value> = (0..51).map(|i| serde_json::json!({"content": format!("Entry {}", i)})).collect();
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "batch-too-large-1",
"method": "tools/call",
"params": {
"name": "batch_store",
"arguments": {
"entries": entries
}
}
}),
)
.await;
assert!(response.get("error").is_some(), "oversized batch_store should return an error");
Ok(())
}
#[tokio::test]
async fn e2e_batch_store_missing_content() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "batch-missing-content-1",
"method": "tools/call",
"params": {
"name": "batch_store",
"arguments": {
"entries": [{"content": "Valid entry"}, {"metadata": {}}]
}
}
}),
)
.await;
assert!(response.get("error").is_some(), "missing batch entry content should return an error");
Ok(())
}
#[tokio::test]
async fn e2e_batch_store_appears_in_tools() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
wait_until_ready(&client, &base).await;
let response = call_jsonrpc(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": "batch-tools-list-1",
"method": "tools/list",
"params": {}
}),
)
.await;
let names: Vec<&str> = response
.get("result")
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
.expect("tools/list result.tools")
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"batch_store"));
Ok(())
}
#[tokio::test]
async fn e2e_existing_store_unchanged() -> anyhow::Result<()> {
let base = base_url();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.expect("reqwest client");
ensure_schema().await;
wait_until_ready(&client, &base).await;
let agent = format!("compat_{}", uuid::Uuid::new_v4());
let _ = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent.clone(), "confirm": true }),
)
.await;
let result = call_tool(&client, &base, "store", serde_json::json!({
"agent_id": agent.clone(),
"content": "Original store still works"
})).await;
let _ = call_tool(
&client,
&base,
"purge",
json!({ "agent_id": agent, "confirm": true }),
)
.await;
assert!(result["success"].as_bool().unwrap_or(false));
Ok(())
}