//! `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::>().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 { 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::(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); }