diff --git a/memoria/crates/memoria-api/tests/api_e2e.rs b/memoria/crates/memoria-api/tests/api_e2e.rs index c13032ee..bfc6aa4f 100644 --- a/memoria/crates/memoria-api/tests/api_e2e.rs +++ b/memoria/crates/memoria-api/tests/api_e2e.rs @@ -3832,11 +3832,20 @@ async fn test_remote_capabilities() { .await .expect("capabilities"); let text = r["content"][0]["text"].as_str().unwrap_or(""); - assert!( - text.contains("remote mode"), - "should mention remote mode, got: {text}" + let parsed: Value = serde_json::from_str(text) + .unwrap_or_else(|e| panic!("capabilities text should be JSON ({e}), got: {text}")); + assert_eq!( + parsed, r["structuredContent"], + "text and structuredContent should carry the same catalog" + ); + let mut advertised = memoria_mcp::tools::list().as_array().unwrap().clone(); + advertised.extend(memoria_mcp::git_tools::list().as_array().unwrap().clone()); + assert_eq!( + parsed, + json!({ "tools": advertised }), + "remote capabilities should match the advertised tool catalog" ); - println!("✅ remote capabilities: {}", &text[..text.len().min(80)]); + println!("✅ remote capabilities: {} tools", advertised.len()); } #[tokio::test] diff --git a/memoria/crates/memoria-mcp/src/remote.rs b/memoria/crates/memoria-mcp/src/remote.rs index e7db66a2..2ce6e028 100644 --- a/memoria/crates/memoria-mcp/src/remote.rs +++ b/memoria/crates/memoria-mcp/src/remote.rs @@ -588,10 +588,7 @@ impl RemoteClient { Ok(Self::mcp_text(&text)) } - "memory_capabilities" => Ok(Self::mcp_text(&format!( - "{}\n[remote mode — connected to Memoria API server]", - crate::tools::MEMORY_CAPABILITIES_TEXT - ))), + "memory_capabilities" => Ok(crate::tools::capabilities_result()), "memory_get_retrieval_params" => { let r = self diff --git a/memoria/crates/memoria-mcp/src/server.rs b/memoria/crates/memoria-mcp/src/server.rs index 7475ba3f..2cef4e75 100644 --- a/memoria/crates/memoria-mcp/src/server.rs +++ b/memoria/crates/memoria-mcp/src/server.rs @@ -401,9 +401,7 @@ async fn dispatch( })), RpcMethod::Ping => Ok(json!({})), RpcMethod::ToolsList => { - let mut all_tools = tools::list().as_array().unwrap().clone(); - all_tools.extend(git_tools::list().as_array().unwrap().clone()); - Ok(json!({"tools": all_tools})) + Ok(tools::tool_catalog()) } RpcMethod::ToolsCall => { let (name, args) = validate_tool_call(Some(&p))?; @@ -453,9 +451,7 @@ async fn dispatch_embedded_owned( })), RpcMethod::Ping => Ok(json!({})), RpcMethod::ToolsList => { - let mut all_tools = tools::list().as_array().unwrap().clone(); - all_tools.extend(git_tools::list().as_array().unwrap().clone()); - Ok(json!({"tools": all_tools})) + Ok(tools::tool_catalog()) } RpcMethod::ToolsCall => { let (name, args) = validate_tool_call(Some(&p))?; diff --git a/memoria/crates/memoria-mcp/src/tools.rs b/memoria/crates/memoria-mcp/src/tools.rs index 52836f91..ff18a256 100644 --- a/memoria/crates/memoria-mcp/src/tools.rs +++ b/memoria/crates/memoria-mcp/src/tools.rs @@ -195,18 +195,23 @@ const TRUST_TIER_DESCRIPTION: &str = concat!( "T4 = speculative, reflective, or otherwise unverified hypothesis." ); -pub(crate) const MEMORY_CAPABILITIES_TEXT: &str = concat!( - "Available tools: memory_store, memory_retrieve, memory_search, ", - "memory_correct, memory_purge, memory_profile, memory_list, ", - "memory_capabilities, memory_governance, memory_consolidate, ", - "memory_reflect, memory_feedback", - "\n\nmemory_store trust_tier guide:", - "\n- T1 (Verified): directly stated or explicitly confirmed facts, preferences, or decisions.", - "\n- T2 (Curated): corrected or manually curated memory replacing an older record.", - "\n- T3 (Inferred): summaries, extracted patterns, or soft conclusions not explicitly confirmed. Prefer T3 if unsure.", - "\n- T4 (Unverified): speculative, reflective, or otherwise unverified hypotheses.", - "\nUse exact values T1/T2/T3/T4; natural-language labels like 'verified' are invalid." -); +/// One catalog for tools/list and memory_capabilities, including Git-backed tools. +pub(crate) fn tool_catalog() -> Value { + let mut all_tools = list().as_array().unwrap().clone(); + all_tools.extend(crate::git_tools::list().as_array().unwrap().clone()); + json!({"tools": all_tools}) +} + +/// Return machine-readable MCP tool discovery while keeping JSON text content +/// for clients that do not yet consume structuredContent. +pub(crate) fn capabilities_result() -> Value { + let catalog = tool_catalog(); + let text = catalog.to_string(); + json!({ + "content": [{"type": "text", "text": text}], + "structuredContent": catalog + }) +} pub fn list() -> Value { json!([ @@ -796,7 +801,7 @@ pub async fn call( Ok(mcp_text(&text)) } - ToolCallName::MemoryCapabilities => Ok(mcp_text(MEMORY_CAPABILITIES_TEXT)), + ToolCallName::MemoryCapabilities => Ok(capabilities_result()), ToolCallName::MemoryGovernance => { let force = args["force"].as_bool().unwrap_or(false); @@ -1573,6 +1578,28 @@ pub fn entity_extract_prompt(text: &str) -> String { mod tests { use super::*; + #[test] + fn memory_capabilities_match_the_public_mcp_catalog() { + let result = capabilities_result(); + let text = result["content"][0]["text"].as_str().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed, result["structuredContent"]); + assert_eq!(parsed, tool_catalog()); + let tools = parsed["tools"].as_array().unwrap(); + for required in ["memory_store", "memory_capabilities", "memory_checkout"] { + assert!( + tools.iter().any(|tool| tool["name"].as_str() == Some(required)), + "missing advertised tool: {required}" + ); + } + for tool in tools { + assert!(tool["name"].is_string()); + assert!(tool["description"].is_string()); + assert!(tool["inputSchema"].is_object()); + } + } + + #[test] fn missing_session_id_preserves_message_and_input_classification() { for args in [ diff --git a/memoria/crates/memoria-mcp/tests/core_tools_e2e.rs b/memoria/crates/memoria-mcp/tests/core_tools_e2e.rs index 1fa7c8ac..afa8d589 100644 --- a/memoria/crates/memoria-mcp/tests/core_tools_e2e.rs +++ b/memoria/crates/memoria-mcp/tests/core_tools_e2e.rs @@ -849,6 +849,25 @@ async fn test_capabilities() { let (svc, uid, _ctx) = setup().await; let r = call("memory_capabilities", json!({}), &svc, &uid).await; let t = text(&r); + let parsed: Value = serde_json::from_str(t) + .unwrap_or_else(|e| panic!("capabilities text should be JSON ({e}), got: {t}")); + assert_eq!( + parsed, r["structuredContent"], + "text and structuredContent should carry the same catalog" + ); + let mut advertised = memoria_mcp::tools::list().as_array().unwrap().clone(); + advertised.extend(memoria_mcp::git_tools::list().as_array().unwrap().clone()); + assert_eq!( + parsed, + json!({ "tools": advertised }), + "capabilities should match the advertised tool catalog" + ); + let names: Vec<&str> = parsed["tools"] + .as_array() + .unwrap() + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); for tool in &[ "memory_store", "memory_retrieve", @@ -857,34 +876,47 @@ async fn test_capabilities() { "memory_purge", "memory_profile", "memory_feedback", + "memory_observe", ] { - assert!(t.contains(tool), "missing {tool}: {t}"); + assert!(names.contains(tool), "missing {tool}: {names:?}"); } - // Hidden ops/debug tools must NOT appear in capabilities + // Unadvertised ops/debug tools must NOT appear in capabilities for hidden in &[ "memory_rebuild_index", "memory_extract_entities", "memory_link_entities", "memory_get_retrieval_params", "memory_tune_params", - "memory_observe", ] { assert!( - !t.contains(hidden), - "hidden tool {hidden} should not be in capabilities: {t}" + !names.contains(hidden), + "hidden tool {hidden} should not be in capabilities: {names:?}" ); } + // Trust-tier guidance now lives in the memory_store input schema. + let store = parsed["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "memory_store") + .expect("memory_store advertised"); + let tier_desc = store["inputSchema"]["properties"]["trust_tier"]["description"] + .as_str() + .expect("trust_tier description"); for hint in &[ - "memory_store trust_tier guide", - "T1 (Verified)", - "T2 (Curated)", - "T3 (Inferred)", - "Prefer T3 if unsure", - "natural-language labels like 'verified' are invalid", + "T1/T2/T3/T4", + "T1 =", + "T2 =", + "T3 =", + "prefer T3 if unsure", + "T4 =", ] { - assert!(t.contains(hint), "missing tier hint {hint}: {t}"); + assert!( + tier_desc.contains(hint), + "missing tier hint {hint}: {tier_desc}" + ); } - println!("✅ capabilities: {t}"); + println!("✅ capabilities: {} tools", names.len()); } // ── 18. unknown tool returns error ────────────────────────────────────────────