Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added memoria/.DS_Store
Binary file not shown.
102 changes: 102 additions & 0 deletions memoria/crates/memoria-api/tests/api_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8703,6 +8703,108 @@ async fn test_mcp_tools_call_memory_store() {
println!("✅ POST /mcp tools/call memory_store: {text}");
}

#[tokio::test]
async fn test_mcp_memory_store_rejects_empty_content() {
let (base, client, _server) = spawn_server().await;
let uid = uid();

for (id, args) in [
(4, json!({})),
(5, json!({"content": ""})),
(6, json!({"content": " \t"})),
] {
let resp = mcp_post_with_headers(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": "memory_store",
"arguments": args
}
}),
&[("X-User-Id", uid.as_str())],
)
.await;

assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], id);
assert!(
resp["error"].is_null(),
"validation should return tool text, not RPC error: {}",
resp["error"]
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("content is required"),
"unexpected response for args={args}: {text}"
);
}

let list = client
.get(format!("{base}/v1/memories"))
.header("X-User-Id", &uid)
.send()
.await
.unwrap();
assert_eq!(list.status(), 200);
assert!(
list.json::<Value>().await.unwrap()["items"]
.as_array()
.unwrap()
.is_empty(),
"empty MCP store must not create memories"
);
println!("✅ POST /mcp memory_store rejects empty content");
}

#[tokio::test]
async fn test_mcp_memory_retrieve_and_search_reject_missing_query() {
let (base, client, _server) = spawn_server().await;
let uid = uid();

for (id, tool, args) in [
(7, "memory_retrieve", json!({})),
(8, "memory_retrieve", json!({"query": ""})),
(9, "memory_retrieve", json!({"query": " \t"})),
(10, "memory_search", json!({})),
(11, "memory_search", json!({"query": ""})),
(12, "memory_search", json!({"query": " \t"})),
] {
let resp = mcp_post_with_headers(
&client,
&base,
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": tool,
"arguments": args
}
}),
&[("X-User-Id", uid.as_str())],
)
.await;

assert_eq!(resp["jsonrpc"], "2.0");
assert_eq!(resp["id"], id);
assert!(
resp["error"].is_null(),
"validation should return tool text, not RPC error: {}",
resp["error"]
);
let text = resp["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("query is required"),
"{tool} unexpected response for args={args}: {text}"
);
}
println!("✅ POST /mcp memory_retrieve/search reject missing query");
}

#[tokio::test]
async fn test_mcp_memory_retrieve_session_scope_end_to_end() {
let (base, client, _server) =
Expand Down
95 changes: 88 additions & 7 deletions memoria/crates/memoria-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,23 @@ fn branch_arg(args: &Value) -> Option<&str> {
.filter(|branch| !branch.is_empty())
}

fn parse_required_str(args: &Value, key: &str, err: &'static str) -> Result<String, &'static str> {
let raw = args[key].as_str().unwrap_or("");
if raw.trim().is_empty() {
Err(err)
} else {
Ok(raw.to_string())
}
}
Comment thread
loveRhythm1990 marked this conversation as resolved.
Comment thread
loveRhythm1990 marked this conversation as resolved.

fn parse_store_content(args: &Value) -> Result<String, &'static str> {
parse_required_str(args, "content", "content is required")
}

fn parse_retrieve_query(args: &Value) -> Result<String, &'static str> {
parse_required_str(args, "query", "query is required")
}

enum ToolCallName {
MemoryStore,
MemoryRetrieve,
Expand Down Expand Up @@ -358,7 +375,10 @@ pub async fn call(
};
match tool {
ToolCallName::MemoryStore => {
let content = args["content"].as_str().unwrap_or("").to_string();
let content = match parse_store_content(&args) {
Ok(content) => content,
Err(msg) => return Ok(mcp_text(msg)),
};
let memory_type = args["memory_type"].as_str().unwrap_or("semantic");
let session_id = args["session_id"].as_str().map(String::from);
let trust_tier = args["trust_tier"]
Expand Down Expand Up @@ -447,7 +467,10 @@ pub async fn call(
}

ToolCallName::MemoryRetrieve | ToolCallName::MemorySearch => {
let query = args["query"].as_str().unwrap_or("").to_string();
let query = match parse_retrieve_query(&args) {
Ok(query) => query,
Err(msg) => return Ok(mcp_text(msg)),
};
let top_k = if matches!(tool, ToolCallName::MemorySearch) {
args["top_k"].as_i64().unwrap_or(10)
} else {
Expand Down Expand Up @@ -511,10 +534,10 @@ pub async fn call(
}

ToolCallName::MemoryCorrect => {
let new_content = args["new_content"].as_str().unwrap_or("");
if new_content.is_empty() {
return Ok(mcp_text("new_content is required"));
}
let new_content = match parse_required_str(&args, "new_content", "new_content is required") {
Ok(s) => s,
Err(msg) => return Ok(mcp_text(msg)),
};
let memory_id = args["memory_id"].as_str().unwrap_or("");
let query = args["query"].as_str().unwrap_or("");

Expand All @@ -541,7 +564,7 @@ pub async fn call(
};

let m = service
.correct_on_branch(user_id, branch_arg(&args), &old_mid, new_content)
.correct_on_branch(user_id, branch_arg(&args), &old_mid, &new_content)
.await?;

Ok(mcp_text(&format!(
Expand Down Expand Up @@ -1436,6 +1459,64 @@ pub fn entity_extract_prompt(text: &str) -> String {
mod tests {
use super::*;

#[test]
fn parse_required_str_rejects_missing_and_blank() {
for val in [json!({}), json!({"k": ""}), json!({"k": " \t\n"})] {
assert!(parse_required_str(&val, "k", "k is required").is_err());
}
}

#[test]
fn parse_required_str_preserves_whitespace_when_valid() {
assert_eq!(
parse_required_str(&json!({"k": " hello "}), "k", "k is required").unwrap(),
" hello "
);
}
Comment thread
loveRhythm1990 marked this conversation as resolved.

#[test]
fn parse_store_content_rejects_missing_and_blank() {
assert_eq!(parse_store_content(&json!({})), Err("content is required"));
assert_eq!(
parse_store_content(&json!({"content": ""})),
Err("content is required")
);
assert_eq!(
parse_store_content(&json!({"content": " \t\n"})),
Err("content is required")
);
}

#[test]
fn parse_retrieve_query_rejects_missing_and_blank() {
assert_eq!(parse_retrieve_query(&json!({})), Err("query is required"));
assert_eq!(
parse_retrieve_query(&json!({"query": ""})),
Err("query is required")
);
assert_eq!(
parse_retrieve_query(&json!({"query": " \t\n"})),
Err("query is required")
);
}

#[test]
fn parse_correct_new_content_rejects_blank() {
// memory_correct now uses parse_required_str — verify whitespace is rejected
assert_eq!(
parse_required_str(&json!({"new_content": ""}), "new_content", "new_content is required"),
Err("new_content is required")
);
assert_eq!(
parse_required_str(&json!({"new_content": " "}), "new_content", "new_content is required"),
Err("new_content is required")
);
assert_eq!(
parse_required_str(&json!({"new_content": "ok"}), "new_content", "new_content is required").unwrap(),
"ok"
);
}

#[test]
fn entity_extract_prompt_truncates_at_char_boundary() {
// 2000 ASCII bytes then a 3-byte Chinese char — must not panic
Expand Down
44 changes: 44 additions & 0 deletions memoria/crates/memoria-mcp/tests/core_tools_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ async fn test_store_rejects_invalid_trust_tier() {
println!("✅ invalid trust_tier rejected explicitly");
}

// ── 2b. memory_store: reject missing/blank content ───────────────────────────

#[tokio::test]
async fn test_store_rejects_empty_content() {
let (svc, uid, _ctx) = setup().await;
for args in [json!({}), json!({"content": ""}), json!({"content": " \t"})] {
let r = call("memory_store", args, &svc, &uid).await;
assert!(
text(&r).contains("content is required"),
"unexpected response: {}",
text(&r)
);
}
assert!(
svc.list_active(&uid, 10).await.unwrap().is_empty(),
"empty content must not create memories"
);
println!("✅ store rejects empty content");
}

// ── 3. memory_retrieve: returns relevant memories ────────────────────────────

#[tokio::test]
Expand Down Expand Up @@ -170,6 +190,30 @@ async fn test_retrieve_empty() {
println!("✅ retrieve empty: {}", text(&r));
}

// ── 3b. memory_retrieve/search: reject missing/blank query ───────────────────

#[tokio::test]
async fn test_retrieve_and_search_reject_missing_query() {
let (svc, uid, _ctx) = setup().await;

for (tool, args) in [
("memory_retrieve", json!({})),
("memory_retrieve", json!({"query": ""})),
("memory_retrieve", json!({"query": " \t"})),
("memory_search", json!({})),
("memory_search", json!({"query": ""})),
("memory_search", json!({"query": " \t"})),
] {
let r = call(tool, args, &svc, &uid).await;
assert!(
text(&r).contains("query is required"),
"{tool} unexpected response: {}",
text(&r)
);
}
println!("✅ retrieve/search reject missing query");
}

#[tokio::test]
async fn test_retrieve_session_scope_only() {
let (svc, uid, _ctx) = setup().await;
Expand Down
44 changes: 44 additions & 0 deletions memoria/crates/memoria-mcp/tests/tools_unit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,26 @@ async fn test_tool_memory_store() {
println!("✅ tool memory_store: {text}");
}

#[tokio::test]
async fn test_tool_memory_store_rejects_empty_content() {
let svc = make_service();
for args in [json!({}), json!({"content": ""}), json!({"content": " "})] {
let result = memoria_mcp::tools::call("memory_store", args, &svc, "u1")
.await
.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("content is required"),
"unexpected response: {text}"
);
}
assert!(
svc.list_active("u1", 10).await.unwrap().is_empty(),
"empty content must not create memories"
);
println!("✅ tool memory_store rejects empty content");
}

#[tokio::test]
async fn test_tool_memory_retrieve_empty() {
let svc = make_service();
Expand All @@ -172,6 +192,30 @@ async fn test_tool_memory_retrieve_empty() {
println!("✅ tool memory_retrieve empty: {text}");
}

#[tokio::test]
async fn test_tool_memory_retrieve_rejects_missing_query() {
let svc = make_service();

for (tool, args) in [
("memory_retrieve", json!({})),
("memory_retrieve", json!({"query": ""})),
("memory_retrieve", json!({"query": " "})),
("memory_search", json!({})),
("memory_search", json!({"query": ""})),
("memory_search", json!({"query": " "})),
] {
let result = memoria_mcp::tools::call(tool, args, &svc, "u1")
.await
.unwrap();
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("query is required"),
"{tool} unexpected response: {text}"
);
}
println!("✅ tool memory_retrieve/search reject missing query");
}

#[tokio::test]
async fn test_tool_memory_retrieve_finds() {
let svc = make_service();
Expand Down
Loading