50e6ce5ed0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
121 lines
6.1 KiB
Rust
121 lines
6.1 KiB
Rust
//! `tools/list`: the core ids, the aliases and a tiny schema checker.
|
|
|
|
mod common;
|
|
|
|
use common::{ask, writable_server};
|
|
use serde_json::{Map, Value, json};
|
|
|
|
/// Every tool of `tools/list`, as objects.
|
|
fn tools(server: &mut notescraft_mcp::Server) -> Vec<Value> {
|
|
let reply = ask(server, &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).to_string());
|
|
reply.get("result").and_then(|result| result.get("tools")).and_then(Value::as_array).cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// The tool named `name`, or a panic naming what is there instead.
|
|
fn tool(tools: &[Value], name: &str) -> Map<String, Value> {
|
|
let found = tools.iter().find(|entry| entry.get("name").and_then(Value::as_str) == Some(name));
|
|
found.cloned().unwrap_or_else(|| panic!("no tool `{name}`")).as_object().cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// The hand-written JSON Schema check the tools must pass: `type: object`,
|
|
/// every declared property a known type or an empty schema, `required` a subset
|
|
/// of `properties`, nothing else accepted.
|
|
fn check_schema(schema: &Value) -> Result<(), String> {
|
|
if schema.get("type").and_then(Value::as_str) != Some("object") {
|
|
return Err(String::from("the schema is not an object"));
|
|
}
|
|
let properties = schema.get("properties").and_then(Value::as_object).ok_or(String::from("no properties"))?;
|
|
for (name, property) in properties {
|
|
match property.get("type").and_then(Value::as_str) {
|
|
None => {
|
|
if property.get("description").is_none() {
|
|
return Err(format!("`{name}` is neither typed nor described"));
|
|
}
|
|
}
|
|
Some(kind) if ["string", "integer", "number", "boolean", "array", "object"].contains(&kind) => {}
|
|
Some(kind) => return Err(format!("`{name}` has the unknown type `{kind}`")),
|
|
}
|
|
}
|
|
if let Some(required) = schema.get("required").and_then(Value::as_array) {
|
|
for name in required {
|
|
let name = name.as_str().ok_or(String::from("a required entry is not a string"))?;
|
|
if !properties.contains_key(name) {
|
|
return Err(format!("`{name}` is required but not declared"));
|
|
}
|
|
}
|
|
} else if schema.get("required").is_some() {
|
|
return Err(String::from("`required` is not an array"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn the_list_holds_the_core_command_tools_and_every_alias() {
|
|
let (_vault, mut server) = writable_server("tools-list");
|
|
let listed = tools(&mut server);
|
|
let names: Vec<&str> = listed.iter().filter_map(|entry| entry.get("name")).filter_map(Value::as_str).collect();
|
|
for expected in ["note_read", "note_write", "vault_info", "links_outgoing", "tags_list", "search", "template_create_note"] {
|
|
assert!(names.contains(&expected), "tools/list must hold `{expected}`: {names:?}");
|
|
}
|
|
for alias in notescraft_mcp::aliases() {
|
|
assert!(names.contains(&alias.name), "the alias `{}` must be listed", alias.name);
|
|
}
|
|
let mut unique = names.clone();
|
|
unique.sort_unstable();
|
|
unique.dedup();
|
|
assert_eq!(unique.len(), names.len(), "a tool name is served twice: {names:?}");
|
|
assert_eq!(names.iter().filter(|name| **name == "vault_info").count(), 1, "the alias does not duplicate the command tool");
|
|
assert!(listed.len() >= 25, "the 25 core commands plus the aliases, got {}", listed.len());
|
|
}
|
|
|
|
#[test]
|
|
fn every_schema_passes_the_checker() {
|
|
let (_vault, mut server) = writable_server("tools-schema");
|
|
for entry in tools(&mut server) {
|
|
let name = entry.get("name").and_then(Value::as_str).unwrap_or("?");
|
|
let schema = entry.get("inputSchema").unwrap_or(&Value::Null);
|
|
check_schema(schema).unwrap_or_else(|reason| panic!("{name}: {reason}"));
|
|
assert!(entry.get("description").and_then(Value::as_str).is_some_and(|doc| !doc.is_empty()), "{name} needs a description");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_note_schema_declares_path_and_include_links() {
|
|
let (_vault, mut server) = writable_server("tools-schema-read");
|
|
let listed = tools(&mut server);
|
|
let read = tool(&listed, "read_note");
|
|
let schema = read.get("inputSchema").expect("a schema");
|
|
assert_eq!(schema.get("required").and_then(Value::as_array).map(Vec::as_slice), Some([Value::from("path")].as_slice()));
|
|
let properties = schema.get("properties").and_then(Value::as_object).expect("properties");
|
|
assert_eq!(properties.get("include_links").and_then(|p| p.get("type")).and_then(Value::as_str), Some("boolean"));
|
|
let append = tool(&listed, "append_note");
|
|
let append_properties = append.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object).expect("properties");
|
|
assert!(append_properties.contains_key("heading"), "the heading param is declared");
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_server_hides_the_write_tools_but_keeps_the_aliases() {
|
|
let (_vault, mut server) = common::read_only_server("tools-ro");
|
|
let names: Vec<String> = tools(&mut server).iter().filter_map(|entry| entry.get("name")).filter_map(Value::as_str).map(str::to_owned).collect();
|
|
for hidden in ["note_write", "note_create", "note_delete", "note_rename", "buffer_save", "daily_open", "theme_set"] {
|
|
assert!(!names.contains(&hidden.to_owned()), "a read-only server must not offer `{hidden}`");
|
|
}
|
|
for shown in ["note_read", "vault_info", "read_note", "search", "list_commands"] {
|
|
assert!(names.contains(&shown.to_owned()), "`{shown}` is a read and stays");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_tool_sends_the_same_params_to_the_command() {
|
|
let (_vault, mut server) = writable_server("tools-command-tool");
|
|
let listed = tools(&mut server);
|
|
assert!(listed.iter().any(|entry| entry.get("name").and_then(Value::as_str) == Some("note_read")));
|
|
let reply = ask(
|
|
&mut server,
|
|
&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "note_read", "arguments": {"path": "Alpha.md"}}}).to_string(),
|
|
);
|
|
let result = reply.get("result").expect("a result");
|
|
assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false));
|
|
assert_eq!(result.get("structuredContent").and_then(|value| value.get("path")).and_then(Value::as_str), Some("Alpha.md"));
|
|
}
|