Files
Marcos Paulo 50e6ce5ed0 feat(mcp): wave 8
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 00:00:21 -03:00

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"));
}