15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
104 lines
5.0 KiB
Rust
104 lines
5.0 KiB
Rust
//! 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<AppSettings>) -> (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<String> {
|
|
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::<Vec<_>>());
|
|
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");
|
|
}
|