109 lines
5.3 KiB
Rust
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)
|
|
}
|