f893c85a18
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
164 lines
7.5 KiB
Rust
164 lines
7.5 KiB
Rust
//! `notescraft-cli mcp` on stdio, driven through the real binary (SPEC 10).
|
|
//!
|
|
//! The client side is hand-written JSON-RPC: a line goes in on stdin, a line
|
|
//! comes out on stdout, and stdout carries nothing else.
|
|
|
|
mod common;
|
|
|
|
use std::path::Path;
|
|
|
|
use common::{Run, exists, note, read_file, run_with_stdin, sample_vault};
|
|
use notescraft_engine::Session;
|
|
use serde_json::{Value, json};
|
|
|
|
/// A client session: `lines` on stdin, the process run to its end.
|
|
fn talk(vault: &Path, args: &[&str], lines: &[Value]) -> Run {
|
|
let stdin: String = lines.iter().map(Value::to_string).collect::<Vec<String>>().join("\n");
|
|
run_with_stdin(vault, args, &format!("{stdin}\n"))
|
|
}
|
|
|
|
/// One `tools/call` request.
|
|
fn call(id: u8, name: &str, arguments: &Value) -> Value {
|
|
json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": name, "arguments": arguments } })
|
|
}
|
|
|
|
/// Every non-blank line of stdout, parsed as JSON.
|
|
fn replies(run: &Run) -> Vec<Value> {
|
|
run.stdout
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).unwrap_or_else(|error| panic!("stdout line is not JSON ({error}): {line}")))
|
|
.collect()
|
|
}
|
|
|
|
/// The `result` of reply `id`, failing the test when the id never came back.
|
|
fn result_of(lines: &[Value], id: u8) -> Value {
|
|
let found =
|
|
lines.iter().find(|line| line.get("id").and_then(Value::as_u64) == Some(u64::from(id))).unwrap_or_else(|| panic!("no reply for id {id} in {lines:?}"));
|
|
found.get("result").cloned().unwrap_or_else(|| panic!("reply {id} carries no result: {found}"))
|
|
}
|
|
|
|
/// The error code of a `tools/call` that came back as a tool error.
|
|
fn error_code(lines: &[Value], id: u8) -> String {
|
|
let result = result_of(lines, id);
|
|
assert_eq!(result.get("isError").and_then(Value::as_bool), Some(true), "expected a tool error: {result}");
|
|
result
|
|
.get("structuredContent")
|
|
.and_then(|content| content.get("error"))
|
|
.and_then(|error| error.get("code"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn the_handshake_lists_the_tools_and_answers_a_call() {
|
|
let vault = sample_vault("mcp-hello");
|
|
let root = vault.canonicalize().expect("a canonical root").to_string_lossy().into_owned();
|
|
let run = talk(
|
|
&vault,
|
|
&["mcp"],
|
|
&[
|
|
json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }),
|
|
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
|
|
json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }),
|
|
call(3, "vault_info", &json!({})),
|
|
],
|
|
);
|
|
assert_eq!(run.code, 0, "{}", run.stderr);
|
|
let lines = replies(&run);
|
|
assert_eq!(lines.len(), 3, "the notification gets no reply: {lines:?}");
|
|
for line in &lines {
|
|
assert_eq!(line.get("jsonrpc").and_then(Value::as_str), Some("2.0"), "every line is a JSON-RPC 2.0 message: {line}");
|
|
}
|
|
|
|
let hello = result_of(&lines, 1);
|
|
assert_eq!(hello.get("protocolVersion").and_then(Value::as_str), Some("2025-06-18"));
|
|
assert_eq!(hello.pointer("/serverInfo/name").and_then(Value::as_str), Some("notescraft"));
|
|
assert!(hello.get("capabilities").is_some_and(|caps| caps.get("tools").is_some()), "tools are advertised");
|
|
|
|
let listed = result_of(&lines, 2);
|
|
let tools = listed.get("tools").and_then(Value::as_array).expect("a tools array");
|
|
let names: Vec<&str> = tools.iter().filter_map(|tool| tool.get("name")).filter_map(Value::as_str).collect();
|
|
for expected in ["vault_info", "read_note", "write_note", "delete_note", "search"] {
|
|
assert!(names.contains(&expected), "{expected} is offered: {names:?}");
|
|
}
|
|
assert!(tools.iter().all(|tool| tool.get("inputSchema").is_some_and(Value::is_object)), "every tool publishes its schema");
|
|
|
|
let info = result_of(&lines, 3);
|
|
assert_eq!(info.get("isError").and_then(Value::as_bool), Some(false));
|
|
let content = info.get("structuredContent").expect("structured content");
|
|
assert_eq!(content.get("notes").and_then(Value::as_u64), Some(5));
|
|
assert_eq!(content.get("read_only").and_then(Value::as_bool), Some(true), "no --write means read-only");
|
|
assert_eq!(content.get("root").and_then(Value::as_str), Some(root.as_str()), "the vault it serves is the one it was given");
|
|
}
|
|
|
|
#[test]
|
|
fn stdout_carries_json_lines_and_nothing_else() {
|
|
let vault = sample_vault("mcp-stdout");
|
|
let run = talk(&vault, &["mcp"], &[json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }), json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" })]);
|
|
assert_eq!(run.code, 0, "{}", run.stderr);
|
|
for line in run.stdout.lines() {
|
|
assert!(line.starts_with('{'), "every stdout line is a JSON object: {line}");
|
|
assert!(serde_json::from_str::<Value>(line).is_ok(), "and it parses: {line}");
|
|
}
|
|
assert_eq!(replies(&run).len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_server_refuses_to_write() {
|
|
let vault = sample_vault("mcp-readonly");
|
|
let request = call(1, "write_note", &json!({ "path": "Notes/New.md", "content": "# New\n" }));
|
|
let run = talk(&vault, &["mcp"], &[request]);
|
|
assert_eq!(run.code, 0, "a refusal is an answer, not a crash: {}", run.stderr);
|
|
assert_eq!(error_code(&replies(&run), 1), "read_only");
|
|
assert!(!exists(&vault, "Notes/New.md"), "nothing was written");
|
|
}
|
|
|
|
#[test]
|
|
fn write_lets_a_note_be_written() {
|
|
let vault = sample_vault("mcp-write");
|
|
let request = call(1, "write_note", &json!({ "path": "Notes/New.md", "content": "# New\n" }));
|
|
let run = talk(&vault, &["mcp", "--write"], &[request]);
|
|
assert_eq!(run.code, 0, "{}", run.stderr);
|
|
let result = result_of(&replies(&run), 1);
|
|
assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false), "{result}");
|
|
assert_eq!(read_file(&vault, "Notes/New.md"), "# New\n");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_needs_its_own_flag() {
|
|
let vault = sample_vault("mcp-delete");
|
|
let request = call(1, "delete_note", &json!({ "path": "Plan.md" }));
|
|
|
|
let refused = talk(&vault, &["mcp", "--write"], std::slice::from_ref(&request));
|
|
assert_eq!(error_code(&replies(&refused), 1), "delete_disabled", "--write alone does not delete");
|
|
assert_eq!(read_file(&vault, "Plan.md"), read_file(&sample_vault("mcp-delete-pristine"), "Plan.md"));
|
|
|
|
let allowed = talk(&vault, &["mcp", "--write", "--allow-delete"], &[request]);
|
|
assert_eq!(allowed.code, 0, "{}", allowed.stderr);
|
|
let result = result_of(&replies(&allowed), 1);
|
|
assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false), "{result}");
|
|
assert!(!exists(&vault, "Plan.md"), "the note went to the trash");
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_server_starts_on_a_locked_vault_but_a_writer_does_not() {
|
|
let vault = sample_vault("mcp-locked");
|
|
let held = Session::open(&vault).expect("take the lock from this process");
|
|
|
|
let read_only = talk(&vault, &["mcp"], &[json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" })]);
|
|
assert_eq!(read_only.code, 0, "a reader does not need the lock: {}", read_only.stderr);
|
|
assert_eq!(replies(&read_only).len(), 1);
|
|
|
|
let refused = talk(&vault, &["mcp", "--write"], &[]);
|
|
assert_eq!(refused.code, 3, "{}", refused.stderr);
|
|
assert!(refused.stderr.contains("locked"), "{}", refused.stderr);
|
|
assert_eq!(refused.stdout, "", "a server that never started says nothing on stdout");
|
|
drop(held);
|
|
|
|
note(&vault, "Notes/After.md", "after the lock\n");
|
|
let after = talk(&vault, &["mcp", "--write"], &[call(1, "read_note", &json!({ "path": "Notes/After.md" }))]);
|
|
assert_eq!(after.code, 0, "the lock was released: {}", after.stderr);
|
|
}
|