//! The knowledge tools over MCP: `vault_context` for the assistant, every //! `knowledge_*` command by name, the per-plugin toggle and the exclusions. mod common; use common::{ask, call_tool, is_error, registry, seed, temp_vault}; use notescraft_engine::{AppSettings, Session, SessionConfig}; use notescraft_mcp::{Server, ServerOptions}; use serde_json::{Value, json}; const TRIP: &str = "# Trip\n\nBook the flight to Lisbon and reserve the hotel near the river.\n"; const SECRET: &str = "# Diary\n\nThe lisbon flight password is hunter2 and the river hotel is secret.\n"; const WRITABLE: ServerOptions = ServerOptions { write: true, allow_delete: false, via_control: false }; fn config() -> SessionConfig { let mut config = SessionConfig::default(); config.knowledge.excluded = vec!["Private".to_owned()]; config } fn fixture(name: &str, options: ServerOptions, settings: Option) -> (common::TempVault, Server) { let vault = temp_vault(name); seed(vault.path(), &[("Trips/lisbon.md", TRIP), ("Private/diary.md", SECRET)]); let mut session = Session::open_with(vault.path(), config()).expect("open"); if let Some(settings) = settings { session.apply_app_settings(&settings).expect("settings"); } (vault, Server::new(session, registry(), options)) } fn names(server: &mut Server) -> Vec { let reply = ask(server, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).to_string()); reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect() } /// The engine's own answer over a twin vault (the server holds the first one's lock). fn engine(id: &str, params: &Value) -> Value { let twin = temp_vault("kn-mcp-twin"); seed(twin.path(), &[("Trips/lisbon.md", TRIP), ("Private/diary.md", SECRET)]); let mut session = Session::open_with(twin.path(), config()).expect("session"); registry().run(id, &mut session, params).expect("engine run") } #[test] fn vault_context_equals_the_engine_and_search_has_the_engine_shape() { let (_vault, mut server) = fixture("kn-mcp-parity", WRITABLE, None); let query = json!({ "query": "lisbon flight hotel" }); let via_alias = call_tool(&mut server, "vault_context", &query); assert!(via_alias.get("error").is_none(), "{via_alias}"); assert_eq!(via_alias, call_tool(&mut server, "knowledge_context", &query)); assert_eq!(via_alias, engine("knowledge.context", &query)); assert_eq!(via_alias["passages"][0]["path"], "Trips/lisbon.md", "{via_alias}"); let search = call_tool(&mut server, "knowledge_search", &query); let direct = engine("knowledge.search", &query); assert_eq!(search["passages"][0]["path"], direct["passages"][0]["path"]); let keys = |value: &Value| value["passages"][0].as_object().map(|o| o.keys().cloned().collect::>()); assert_eq!(keys(&search), keys(&direct)); } #[test] fn excluded_folders_never_appear() { let (_vault, mut server) = fixture("kn-mcp-excluded", WRITABLE, None); for tool in ["vault_context", "knowledge_search", "knowledge_context"] { let out = call_tool(&mut server, tool, &json!({ "query": "password hunter2 secret" })); assert!(!out.to_string().contains("hunter2") && !out.to_string().contains("Private/"), "{tool}: {out}"); } } #[test] fn the_assistant_list_holds_vault_context_and_only_when_enabled() { let options = ServerOptions { via_control: true, ..WRITABLE }; let (_vault, mut server) = fixture("kn-mcp-list", options, None); let listed = names(&mut server); assert!(listed.iter().any(|name| name == "vault_context"), "{listed:?}"); assert!(!listed.iter().any(|name| name.starts_with("knowledge_")), "{listed:?}"); assert!(listed.len() <= 24, "{listed:?}"); let mut settings = AppSettings::default(); settings.assistant.plugin_tools.insert("knowledge".to_owned(), false); let (_vault, mut off) = fixture("kn-mcp-off", options, Some(settings)); assert!(!names(&mut off).iter().any(|name| name == "vault_context")); assert!(is_error(&mut off, "vault_context", &json!({ "query": "lisbon" }))); } #[test] fn a_plain_server_lists_every_knowledge_command_and_run_command_reaches_them() { let (_vault, mut server) = fixture("kn-mcp-plain", WRITABLE, None); let listed = names(&mut server); for tool in ["knowledge_search", "knowledge_context", "knowledge_related", "knowledge_status", "knowledge_rebuild", "vault_context"] { assert!(listed.iter().any(|name| name == tool), "{tool}"); } let status = call_tool(&mut server, "run_command", &json!({ "id": "knowledge.status" })); assert!(status.get("passages").is_some(), "{status}"); } #[test] fn a_read_only_server_answers_reads() { let (_vault, mut server) = fixture("kn-mcp-ro", ServerOptions { write: false, ..WRITABLE }, None); assert!(!is_error(&mut server, "vault_context", &json!({ "query": "lisbon" }))); assert!(!is_error(&mut server, "knowledge_search", &json!({ "query": "lisbon" }))); assert!(is_error(&mut server, "vault_context", &json!({})), "`query` is required"); }