Files
2026-10-15 02:00:16 -03:00

109 lines
5.3 KiB
Rust

//! Plugin tools over MCP (SPEC 10.2): `plugin_<id>_<command>` 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<Session> {
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<String> {
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<Value> {
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)
}