//! Plugin tools over MCP (SPEC 10.2): `plugin__` tools are listed //! exactly when their per-plugin toggle is on, and calls go through the gate. use notescraft_engine::{CommandResult, CommandSpec, ParamKind, ParamSpec, Registry, Session}; use serde_json::{Value, json}; mod common; use common::seeded_vault; use crate::common::registry as core_registry; use notescraft_mcp::ServerOptions; /// Registry options that allow writes, so the test isolates the toggle rule. const WRITABLE: ServerOptions = ServerOptions { write: true, allow_delete: true, via_control: false }; /// The `plugin.demo.go` body: echoes its args (never runs in these tests). #[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")] fn go(_session: &mut Session, params: &Value) -> CommandResult { Ok(params.clone()) } /// A registry with one plugin command registered by hand. fn plugin_registry() -> Registry { let mut registry = core_registry(); registry .register(CommandSpec::new("plugin.demo.go", "Go", "`go`, run by the plugin demo", |_: &Session| true, go).with_param(ParamSpec::optional( "args", ParamKind::Any, "the JSON arguments handed to the plugin", ))) .expect("register the plugin command"); registry } /// A server whose assistant toggle for `demo` is `on`. fn server(name: &str, on: bool) -> notescraft_mcp::Server { let vault = seeded_vault(name); let session = Session::open(vault.path()).expect("open the session"); let mut session = session; let mut settings = notescraft_engine::AppSettings::default(); settings.assistant.plugin_tools.insert(String::from("demo"), on); session.apply_app_settings(&settings).expect("apply the settings"); notescraft_mcp::Server::new(session, plugin_registry(), WRITABLE) } /// Toggle off: the tool is not listed, and a call is refused with its own code. #[test] fn a_plugin_tool_needs_its_toggle_for_listing_and_calling() { let mut off = server("plt-off", false); let listed = listed(&mut off); assert!(!listed.iter().any(|name| name == "plugin_demo_go"), "{listed:?}"); assert!(listed.iter().any(|name| name == "note_read"), "core tools are still listed"); let answer = call(&mut off, "plugin_demo_go", &json!({ "args": {} })); assert_eq!(answer.get("error").and_then(|error| error.get("code")).and_then(Value::as_str), Some("assistant_plugin_tool_off"), "{answer}"); } /// Toggle on: the tool is listed with its schema and runs (the plugin's own /// permission check would apply inside the engine). #[test] fn a_toggled_plugin_tool_is_listed_and_runs() { let mut on = server("plt-on", true); let tools = tools_of(&mut on); let tool = tools.iter().find(|tool| tool.get("name").and_then(Value::as_str) == Some("plugin_demo_go")).cloned().expect("listed"); let schema = tool.get("inputSchema").cloned().expect("a schema"); assert!(schema.get("properties").is_some(), "{schema}"); let answer = call(&mut on, "plugin_demo_go", &json!({ "args": { "x": 1 } })); assert!(!common::is_error(&mut on, "plugin_demo_go", &json!({ "args": { "x": 1 } })), "the call is not a tool error"); assert_eq!(answer, json!({ "args": { "x": 1 } }), "the echo body ran through the gate"); } /// A plugin tool whose plugin is not enabled is offered to nobody (SPEC 10.2): /// the registry's `enabled` flag carries "the plugin has the permissions the /// call needs", so the list stays honest. #[test] fn a_disabled_plugin_command_is_never_listed() { let mut registry = core_registry(); registry.register(CommandSpec::new("plugin.demo.go", "Go", "`go`", |_: &Session| false, go)).expect("register the disabled plugin command"); let vault = seeded_vault("plt-disabled"); let mut session = Session::open(vault.path()).expect("open the session"); let mut settings = notescraft_engine::AppSettings::default(); settings.assistant.plugin_tools.insert(String::from("demo"), true); session.apply_app_settings(&settings).expect("apply the settings"); let mut server = notescraft_mcp::Server::new(session, registry, WRITABLE); let listed = listed(&mut server); assert!(!listed.iter().any(|name| name == "plugin_demo_go"), "a disabled plugin command is not offered: {listed:?}"); assert!(common::is_error(&mut server, "plugin_demo_go", &json!({ "args": {} })), "and it does not run either"); } /// A plugin id the registry does not hold stays an ordinary unknown tool. #[test] fn an_unknown_plugin_tool_is_unknown_rather_than_toggled() { let mut on = server("plt-unknown", true); let answer = call(&mut on, "plugin.nobody.whatever", &json!({})); assert_eq!(answer.get("error").and_then(|error| error.get("code")).and_then(Value::as_str), Some("unknown_tool"), "{answer}"); } fn listed(server: &mut notescraft_mcp::Server) -> Vec { tools_of(server).iter().filter_map(|tool| tool.get("name").and_then(Value::as_str)).map(str::to_owned).collect() } fn tools_of(server: &mut notescraft_mcp::Server) -> Vec { let reply = common::ask(server, r#"{"id":3,"method":"tools/list"}"#); reply.get("result").and_then(|result| result.get("tools")).and_then(Value::as_array).cloned().unwrap_or_default() } fn call(server: &mut notescraft_mcp::Server, name: &str, arguments: &Value) -> Value { common::call_tool(server, name, arguments) }