diff options
| author | main <main@swarm.moe> | 2026-03-19 10:15:18 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 10:15:18 -0400 |
| commit | 7b9bd8b42883f82b090718175b8316296ef18236 (patch) | |
| tree | 16f2c70b0f630c7757d72a20bd90d17c2e3a8414 /crates/fidget-spinner-cli/tests | |
| download | fidget_spinner-7b9bd8b42883f82b090718175b8316296ef18236.zip | |
Initial Fidget Spinner MVP
Diffstat (limited to 'crates/fidget-spinner-cli/tests')
| -rw-r--r-- | crates/fidget-spinner-cli/tests/mcp_hardening.rs | 424 |
1 files changed, 424 insertions, 0 deletions
diff --git a/crates/fidget-spinner-cli/tests/mcp_hardening.rs b/crates/fidget-spinner-cli/tests/mcp_hardening.rs new file mode 100644 index 0000000..8d3cd9d --- /dev/null +++ b/crates/fidget-spinner-cli/tests/mcp_hardening.rs @@ -0,0 +1,424 @@ +use std::fs; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use camino::Utf8PathBuf; +use clap as _; +use dirs as _; +use fidget_spinner_core::NonEmptyText; +use fidget_spinner_store_sqlite::{ListNodesQuery, ProjectStore}; +use serde as _; +use serde_json::{Value, json}; +use time as _; +use uuid as _; + +type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>; + +fn must<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> TestResult<T> { + result.map_err(|error| io::Error::other(format!("{context}: {error}")).into()) +} + +fn must_some<T>(value: Option<T>, context: &str) -> TestResult<T> { + value.ok_or_else(|| io::Error::other(context).into()) +} + +fn temp_project_root(name: &str) -> TestResult<Utf8PathBuf> { + let root = std::env::temp_dir().join(format!( + "fidget_spinner_mcp_{name}_{}_{}", + std::process::id(), + must( + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH), + "current time after unix epoch", + )? + .as_nanos() + )); + must(fs::create_dir_all(&root), "create temp project root")?; + Ok(Utf8PathBuf::from(root.to_string_lossy().into_owned())) +} + +fn init_project(root: &Utf8PathBuf) -> TestResult { + let _store = must( + ProjectStore::init( + root, + must(NonEmptyText::new("mcp test project"), "display name")?, + must(NonEmptyText::new("local.mcp.test"), "namespace")?, + ), + "init project store", + )?; + Ok(()) +} + +fn binary_path() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_fidget-spinner-cli")) +} + +struct McpHarness { + child: Child, + stdin: ChildStdin, + stdout: BufReader<ChildStdout>, +} + +impl McpHarness { + fn spawn(project_root: Option<&Utf8PathBuf>, envs: &[(&str, String)]) -> TestResult<Self> { + let mut command = Command::new(binary_path()); + let _ = command + .arg("mcp") + .arg("serve") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + if let Some(project_root) = project_root { + let _ = command.arg("--project").arg(project_root.as_str()); + } + for (key, value) in envs { + let _ = command.env(key, value); + } + let mut child = must(command.spawn(), "spawn mcp host")?; + let stdin = must_some(child.stdin.take(), "host stdin")?; + let stdout = BufReader::new(must_some(child.stdout.take(), "host stdout")?); + Ok(Self { + child, + stdin, + stdout, + }) + } + + fn initialize(&mut self) -> TestResult<Value> { + self.request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "mcp-hardening-test", "version": "0" } + } + })) + } + + fn notify_initialized(&mut self) -> TestResult { + self.notify(json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + })) + } + + fn tools_list(&mut self) -> TestResult<Value> { + self.request(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + })) + } + + fn bind_project(&mut self, id: u64, path: &Utf8PathBuf) -> TestResult<Value> { + self.call_tool(id, "project.bind", json!({ "path": path.as_str() })) + } + + fn call_tool(&mut self, id: u64, name: &str, arguments: Value) -> TestResult<Value> { + self.request(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments, + } + })) + } + + fn request(&mut self, message: Value) -> TestResult<Value> { + let encoded = must(serde_json::to_string(&message), "request json")?; + must(writeln!(self.stdin, "{encoded}"), "write request")?; + must(self.stdin.flush(), "flush request")?; + let mut line = String::new(); + let byte_count = must(self.stdout.read_line(&mut line), "read response")?; + if byte_count == 0 { + return Err(io::Error::other("unexpected EOF reading response").into()); + } + must(serde_json::from_str(&line), "response json") + } + + fn notify(&mut self, message: Value) -> TestResult { + let encoded = must(serde_json::to_string(&message), "notify json")?; + must(writeln!(self.stdin, "{encoded}"), "write notify")?; + must(self.stdin.flush(), "flush notify")?; + Ok(()) + } +} + +impl Drop for McpHarness { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn tool_content(response: &Value) -> &Value { + &response["result"]["structuredContent"] +} + +#[test] +fn cold_start_exposes_health_and_telemetry() -> TestResult { + let project_root = temp_project_root("cold_start")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn(None, &[])?; + let initialize = harness.initialize()?; + assert_eq!( + initialize["result"]["protocolVersion"].as_str(), + Some("2025-11-25") + ); + harness.notify_initialized()?; + + let tools = harness.tools_list()?; + let tool_count = must_some(tools["result"]["tools"].as_array(), "tools array")?.len(); + assert!(tool_count >= 18); + + let health = harness.call_tool(3, "system.health", json!({}))?; + assert_eq!( + tool_content(&health)["initialization"]["ready"].as_bool(), + Some(true) + ); + assert_eq!( + tool_content(&health)["initialization"]["seed_captured"].as_bool(), + Some(true) + ); + assert_eq!( + tool_content(&health)["binding"]["bound"].as_bool(), + Some(false) + ); + + let telemetry = harness.call_tool(4, "system.telemetry", json!({}))?; + assert!(tool_content(&telemetry)["requests"].as_u64().unwrap_or(0) >= 3); + + let skills = harness.call_tool(15, "skill.list", json!({}))?; + let skill_names = must_some( + tool_content(&skills)["skills"].as_array(), + "bundled skills array", + )? + .iter() + .filter_map(|skill| skill["name"].as_str()) + .collect::<Vec<_>>(); + assert!(skill_names.contains(&"fidget-spinner")); + assert!(skill_names.contains(&"frontier-loop")); + + let base_skill = harness.call_tool(16, "skill.show", json!({"name": "fidget-spinner"}))?; + assert_eq!( + tool_content(&base_skill)["name"].as_str(), + Some("fidget-spinner") + ); + Ok(()) +} + +#[test] +fn safe_request_retries_after_worker_crash() -> TestResult { + let project_root = temp_project_root("crash_retry")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn( + None, + &[( + "FIDGET_SPINNER_MCP_TEST_HOST_CRASH_ONCE_KEY", + "tools/call:project.status".to_owned(), + )], + )?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + let bind = harness.bind_project(3, &project_root)?; + assert_eq!(bind["result"]["isError"].as_bool(), Some(false)); + + let response = harness.call_tool(5, "project.status", json!({}))?; + assert_eq!(response["result"]["isError"].as_bool(), Some(false)); + + let telemetry = harness.call_tool(6, "system.telemetry", json!({}))?; + assert_eq!(tool_content(&telemetry)["retries"].as_u64(), Some(1)); + assert_eq!( + tool_content(&telemetry)["worker_restarts"].as_u64(), + Some(1) + ); + Ok(()) +} + +#[test] +fn safe_request_retries_after_worker_transient_fault() -> TestResult { + let project_root = temp_project_root("transient_retry")?; + init_project(&project_root)?; + let marker = project_root.join("transient_once.marker"); + + let mut harness = McpHarness::spawn( + None, + &[ + ( + "FIDGET_SPINNER_MCP_TEST_WORKER_TRANSIENT_ONCE_KEY", + "tools/call:project.status".to_owned(), + ), + ( + "FIDGET_SPINNER_MCP_TEST_WORKER_TRANSIENT_ONCE_MARKER", + marker.to_string(), + ), + ], + )?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + let bind = harness.bind_project(12, &project_root)?; + assert_eq!(bind["result"]["isError"].as_bool(), Some(false)); + + let response = harness.call_tool(13, "project.status", json!({}))?; + assert_eq!(response["result"]["isError"].as_bool(), Some(false)); + + let telemetry = harness.call_tool(14, "system.telemetry", json!({}))?; + assert_eq!(tool_content(&telemetry)["retries"].as_u64(), Some(1)); + assert_eq!( + tool_content(&telemetry)["worker_restarts"].as_u64(), + Some(1) + ); + Ok(()) +} + +#[test] +fn side_effecting_request_is_not_replayed_after_worker_crash() -> TestResult { + let project_root = temp_project_root("no_replay")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn( + None, + &[( + "FIDGET_SPINNER_MCP_TEST_HOST_CRASH_ONCE_KEY", + "tools/call:research.record".to_owned(), + )], + )?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + let bind = harness.bind_project(6, &project_root)?; + assert_eq!(bind["result"]["isError"].as_bool(), Some(false)); + + let response = harness.call_tool( + 7, + "research.record", + json!({ + "title": "should not duplicate", + "body": "host crash before worker execution", + }), + )?; + assert_eq!(response["result"]["isError"].as_bool(), Some(true)); + + let nodes = harness.call_tool(8, "node.list", json!({}))?; + assert_eq!( + must_some(tool_content(&nodes).as_array(), "node list")?.len(), + 0 + ); + + let telemetry = harness.call_tool(9, "system.telemetry", json!({}))?; + assert_eq!(tool_content(&telemetry)["retries"].as_u64(), Some(0)); + Ok(()) +} + +#[test] +fn forced_rollout_preserves_initialized_state() -> TestResult { + let project_root = temp_project_root("rollout")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn( + None, + &[( + "FIDGET_SPINNER_MCP_TEST_FORCE_ROLLOUT_KEY", + "tools/call:project.status".to_owned(), + )], + )?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + let bind = harness.bind_project(9, &project_root)?; + assert_eq!(bind["result"]["isError"].as_bool(), Some(false)); + + let first = harness.call_tool(10, "project.status", json!({}))?; + assert_eq!(first["result"]["isError"].as_bool(), Some(false)); + + let second = harness.call_tool(11, "project.status", json!({}))?; + assert_eq!(second["result"]["isError"].as_bool(), Some(false)); + + let telemetry = harness.call_tool(12, "system.telemetry", json!({}))?; + assert_eq!(tool_content(&telemetry)["host_rollouts"].as_u64(), Some(1)); + Ok(()) +} + +#[test] +fn unbound_project_tools_fail_with_bind_hint() -> TestResult { + let mut harness = McpHarness::spawn(None, &[])?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let response = harness.call_tool(20, "project.status", json!({}))?; + assert_eq!(response["result"]["isError"].as_bool(), Some(true)); + let message = response["result"]["structuredContent"]["message"].as_str(); + assert!(message.is_some_and(|message| message.contains("project.bind"))); + Ok(()) +} + +#[test] +fn bind_retargets_writes_to_sibling_project_root() -> TestResult { + let spinner_root = temp_project_root("spinner_root")?; + let libgrid_root = temp_project_root("libgrid_root")?; + init_project(&spinner_root)?; + init_project(&libgrid_root)?; + let notes_dir = libgrid_root.join("notes"); + must( + fs::create_dir_all(notes_dir.as_std_path()), + "create nested notes dir", + )?; + + let mut harness = McpHarness::spawn(Some(&spinner_root), &[])?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let initial_status = harness.call_tool(30, "project.status", json!({}))?; + assert_eq!( + tool_content(&initial_status)["project_root"].as_str(), + Some(spinner_root.as_str()) + ); + + let rebind = harness.bind_project(31, ¬es_dir)?; + assert_eq!(rebind["result"]["isError"].as_bool(), Some(false)); + assert_eq!( + tool_content(&rebind)["project_root"].as_str(), + Some(libgrid_root.as_str()) + ); + + let status = harness.call_tool(32, "project.status", json!({}))?; + assert_eq!( + tool_content(&status)["project_root"].as_str(), + Some(libgrid_root.as_str()) + ); + + let note = harness.call_tool( + 33, + "note.quick", + json!({ + "title": "libgrid dogfood note", + "body": "rebind should redirect writes", + }), + )?; + assert_eq!(note["result"]["isError"].as_bool(), Some(false)); + + let spinner_store = must(ProjectStore::open(&spinner_root), "open spinner store")?; + let libgrid_store = must(ProjectStore::open(&libgrid_root), "open libgrid store")?; + assert_eq!( + must( + spinner_store.list_nodes(ListNodesQuery::default()), + "list spinner nodes after rebind" + )? + .len(), + 0 + ); + assert_eq!( + must( + libgrid_store.list_nodes(ListNodesQuery::default()), + "list libgrid nodes after rebind" + )? + .len(), + 1 + ); + Ok(()) +} |