//! End-to-end test for the `mcp_registry` connection lifecycle. //! //! Hermetic: spawns the `test-mcp-stub` binary (built alongside this test //! by Cargo and exposed via `CARGO_BIN_EXE_test-mcp-stub`) as the MCP //! subprocess. No npx, no network. Validates that //! `store::insert_server` → `connections::connect` → `connections::call_tool` //! → `connections::disconnect` round-trips correctly through the unified //! `mcp_client::McpStdioClient` transport. use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::mcp_registry::connections; use openhuman_core::openhuman::mcp_registry::store; use openhuman_core::openhuman::mcp_registry::types::{CommandKind, InstalledServer, Transport}; fn fresh_workspace_config() -> (tempfile::TempDir, Config) { let tmp = tempfile::tempdir().expect("tempdir"); let mut cfg = Config::default(); cfg.workspace_dir = tmp.path().to_path_buf(); (tmp, cfg) } fn make_installed_server() -> InstalledServer { let stub_path = env!("CARGO_BIN_EXE_test-mcp-stub"); InstalledServer { server_id: format!("test-{}", uuid::Uuid::new_v4()), qualified_name: "@openhuman-test/echo".to_string(), display_name: "Test Echo".to_string(), description: Some("Stub MCP server used by mcp_registry_e2e tests.".into()), icon_url: None, command_kind: CommandKind::Binary, command: stub_path.to_string(), args: Vec::new(), env_keys: Vec::new(), config: None, installed_at: 0, last_connected_at: None, transport: Transport::Stdio, enabled: true, } } #[tokio::test] async fn connect_lists_one_tool_then_disconnect() { let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); // Insert into the store so `all_status` (which reads from store) sees it, // and so a follow-up `boot::spawn_installed_servers` would pick it up. store::insert_server(&cfg, &server).expect("insert installed server"); // Connect: spawns the stub subprocess and runs `initialize` + `tools/list`. let tools = connections::connect(&cfg, &server) .await .expect("connect succeeds"); assert_eq!(tools.len(), 1, "stub advertises one tool"); assert_eq!(tools[0].name, "echo"); assert!(tools[0].input_schema.is_object()); // Status reflects the live connection. let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .expect("status entry present"); assert_eq!(mine.tool_count, 1); // Call the `echo` tool and verify the response payload. let result = connections::call_tool( &server.server_id, "echo", serde_json::json!({ "message": "hello mcp" }), ) .await .expect("call_tool succeeds"); let text = result .get("content") .and_then(|c| c.as_array()) .and_then(|arr| arr.first()) .and_then(|first| first.get("text")) .and_then(|t| t.as_str()) .unwrap_or(""); assert_eq!(text, "hello mcp", "echo tool returns the input verbatim"); // Disconnect: removes from the registry and closes the subprocess. let removed = connections::disconnect(&server.server_id).await; assert!(removed, "disconnect drops the live connection"); // Subsequent call fails because the server_id is no longer connected. let err = connections::call_tool( &server.server_id, "echo", serde_json::json!({ "message": "post-disconnect" }), ) .await .expect_err("call_tool fails after disconnect"); assert!(err.contains("not connected")); } #[tokio::test] async fn unknown_tool_call_returns_error() { let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); store::insert_server(&cfg, &server).expect("insert installed server"); connections::connect(&cfg, &server).await.expect("connect"); let err = connections::call_tool(&server.server_id, "does_not_exist", serde_json::json!({})) .await .expect_err("stub rejects unknown tools"); assert!( err.to_lowercase().contains("unknown tool") || err.contains("error"), "expected unknown-tool error, got: {err}" ); let _ = connections::disconnect(&server.server_id).await; } #[tokio::test] async fn failed_connect_records_last_error() { let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.command = "/this/path/does/not/exist".to_string(); store::insert_server(&cfg, &server).expect("insert installed server"); let err = connections::connect(&cfg, &server) .await .expect_err("connect should fail for bogus command"); assert!(!err.to_string().is_empty()); let recorded = connections::last_error_for(&server.server_id).await; assert!( recorded.is_some(), "LAST_ERRORS must hold the connect failure for server_id={}", server.server_id ); } #[tokio::test] async fn successful_connect_clears_last_error() { let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.command = "/nonexistent".to_string(); let _ = connections::connect(&cfg, &server).await; assert!(connections::last_error_for(&server.server_id) .await .is_some()); server.command = env!("CARGO_BIN_EXE_test-mcp-stub").to_string(); connections::connect(&cfg, &server) .await .expect("real connect succeeds"); assert!( connections::last_error_for(&server.server_id) .await .is_none(), "successful connect must clear the prior error" ); let _ = connections::disconnect(&server.server_id).await; } #[tokio::test] async fn status_priority_disabled_outranks_connected() { let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.enabled = false; store::insert_server(&cfg, &server).expect("insert"); let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .expect("status entry present"); assert_eq!( mine.status.as_str(), "disabled", "disabled server reports `disabled` even before any connect attempt" ); assert!(mine.last_error.is_none()); } #[tokio::test] async fn status_reflects_last_connect_error() { let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.command = "/nonexistent".to_string(); store::insert_server(&cfg, &server).expect("insert"); let _ = connections::connect(&cfg, &server).await; let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .unwrap(); assert_eq!(mine.status.as_str(), "error"); assert!( mine.last_error .as_deref() .map(|s| !s.is_empty()) .unwrap_or(false), "last_error populated" ); } #[tokio::test] async fn boot_skips_disabled_servers_and_records_errors() { use openhuman_core::openhuman::mcp_registry::boot; let (_tmp, cfg) = fresh_workspace_config(); // Server A: enabled, real stub → connects. let mut a = make_installed_server(); a.server_id = format!("a-{}", uuid::Uuid::new_v4()); store::insert_server(&cfg, &a).expect("insert a"); // Server B: enabled but command does not exist → records error, doesn't crash boot. let mut b = make_installed_server(); b.server_id = format!("b-{}", uuid::Uuid::new_v4()); b.command = "/nonexistent-mcp".to_string(); store::insert_server(&cfg, &b).expect("insert b"); // Server C: disabled AND command is bogus. If boot ever attempts to // connect this server, the bogus command will fail and LAST_ERRORS will // hold an entry. The skip is the only way the post-boot last_error stays // None — so the assertion below proves the skip actually fired, not just // that the Disabled-priority logic masked the failure. let mut c = make_installed_server(); c.server_id = format!("c-{}", uuid::Uuid::new_v4()); c.enabled = false; c.command = "/nonexistent-disabled-server".to_string(); store::insert_server(&cfg, &c).expect("insert c"); boot::spawn_installed_servers(&cfg).await; // A is connected; B recorded an error; C never attempted (no error // recorded despite the bogus command). let statuses = connections::all_status(&cfg).await; let by_id = |id: &str| { statuses .iter() .find(|s| s.server_id == id) .cloned() .unwrap() }; assert_eq!(by_id(&a.server_id).status.as_str(), "connected"); assert_eq!(by_id(&b.server_id).status.as_str(), "error"); assert_eq!(by_id(&c.server_id).status.as_str(), "disabled"); assert!( connections::last_error_for(&c.server_id).await.is_none(), "disabled server with bogus command must not have been connect-attempted" ); let _ = connections::disconnect(&a.server_id).await; } #[tokio::test] async fn set_enabled_false_disconnects_running_server() { use openhuman_core::openhuman::mcp_registry::ops; let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); store::insert_server(&cfg, &server).expect("insert"); connections::connect(&cfg, &server).await.expect("connect"); let outcome = ops::mcp_clients_set_enabled(&cfg, server.server_id.clone(), false) .await .expect("set_enabled ok"); assert_eq!(outcome.value["enabled"], serde_json::json!(false)); let loaded = store::get_server(&cfg, &server.server_id).unwrap(); assert!(!loaded.enabled); let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .unwrap(); assert_eq!(mine.status.as_str(), "disabled"); } #[tokio::test] async fn connect_refuses_disabled_server() { use openhuman_core::openhuman::mcp_registry::ops; let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.enabled = false; store::insert_server(&cfg, &server).expect("insert"); let err = ops::mcp_clients_connect(&cfg, server.server_id.clone()) .await .expect_err("connect must reject disabled server"); assert!(err.to_lowercase().contains("disabled"), "got: {err}"); } #[tokio::test] async fn set_enabled_true_clears_disabled_status_but_does_not_auto_connect() { use openhuman_core::openhuman::mcp_registry::ops; let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.enabled = false; store::insert_server(&cfg, &server).expect("insert"); ops::mcp_clients_set_enabled(&cfg, server.server_id.clone(), true) .await .expect("set_enabled true ok"); let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .unwrap(); assert_eq!( mine.status.as_str(), "disconnected", "re-enabling alone must not bring up the subprocess; the user calls connect explicitly" ); } #[tokio::test] async fn update_env_on_disabled_server_persists_but_does_not_reconnect() { use openhuman_core::openhuman::mcp_registry::ops; use std::collections::HashMap; let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.enabled = false; store::insert_server(&cfg, &server).expect("insert"); let mut env = HashMap::new(); env.insert("API_KEY".to_string(), "deadbeef".to_string()); let outcome = ops::mcp_clients_update_env(&cfg, server.server_id.clone(), env) .await .expect("update_env on disabled server returns Ok"); assert_eq!( outcome.value["status"], "disabled", "disabled server reports status=disabled instead of reconnecting" ); let statuses = connections::all_status(&cfg).await; let mine = statuses .iter() .find(|s| s.server_id == server.server_id) .unwrap(); assert_eq!(mine.status.as_str(), "disabled"); } #[tokio::test] async fn update_env_merges_partial_update_preserving_other_secrets() { // Regression for the #3648 review: `update_env` must MERGE a partial // payload over the stored env, not replace-all. The connect modal can only // send the field the user just typed (it cannot display existing secrets), // so a replace-all would silently erase every other stored credential. use openhuman_core::openhuman::mcp_registry::ops; use std::collections::HashMap; let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); // Disabled so update_env persists without attempting a live reconnect — // we only assert the persisted env here. server.enabled = false; store::insert_server(&cfg, &server).expect("insert"); // Seed two stored secrets. let mut initial = HashMap::new(); initial.insert("API_KEY".to_string(), "key-1".to_string()); initial.insert("OTHER_SECRET".to_string(), "other-1".to_string()); store::set_env_values(&cfg, &server.server_id, &initial).expect("seed env"); // Partial update: only API_KEY, as the connect modal would send for a // single edited field. let mut partial = HashMap::new(); partial.insert("API_KEY".to_string(), "key-2".to_string()); ops::mcp_clients_update_env(&cfg, server.server_id.clone(), partial) .await .expect("update_env returns Ok"); let stored = store::load_env_values(&cfg, &server.server_id).expect("load env"); assert_eq!( stored.get("API_KEY").map(String::as_str), Some("key-2"), "the supplied value must be updated" ); assert_eq!( stored.get("OTHER_SECRET").map(String::as_str), Some("other-1"), "an un-supplied secret must be PRESERVED, not erased by a partial update" ); } // ── Reconnect supervisor (#3312) ─────────────────────────────────────────────── #[tokio::test] async fn probe_alive_reflects_transport_liveness() { let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); store::insert_server(&cfg, &server).expect("insert installed server"); connections::connect(&cfg, &server).await.expect("connect"); assert!(connections::is_connected(&server.server_id).await); assert!( connections::probe_alive(&server.server_id, std::time::Duration::from_secs(8)).await, "a live stub answers the tools/list probe" ); connections::disconnect(&server.server_id).await; assert!(!connections::is_connected(&server.server_id).await); assert!( !connections::probe_alive(&server.server_id, std::time::Duration::from_secs(8)).await, "a disconnected server is not alive" ); } #[tokio::test] async fn supervisor_reconnects_a_dropped_server() { use openhuman_core::openhuman::mcp_registry::supervisor; let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); store::insert_server(&cfg, &server).expect("insert installed server"); // Bring it up, then simulate a silent transport drop by disconnecting while // it stays installed + enabled in the store. connections::connect(&cfg, &server).await.expect("connect"); connections::disconnect(&server.server_id).await; assert!(!connections::is_connected(&server.server_id).await); // One supervisor tick should notice the enabled-but-disconnected server and // reconnect it. supervisor::run_single_tick_for_test(&cfg).await; assert!( connections::is_connected(&server.server_id).await, "supervisor reconnects a dropped-but-installed server" ); assert!(connections::probe_alive(&server.server_id, std::time::Duration::from_secs(8)).await); connections::disconnect(&server.server_id).await; } #[tokio::test] async fn supervisor_leaves_a_healthy_connection_intact() { use openhuman_core::openhuman::mcp_registry::supervisor; let (_tmp, cfg) = fresh_workspace_config(); let server = make_installed_server(); store::insert_server(&cfg, &server).expect("insert installed server"); connections::connect(&cfg, &server).await.expect("connect"); // A tick over a healthy server must keep it connected (probe succeeds → no // disconnect/reconnect churn). supervisor::run_single_tick_for_test(&cfg).await; assert!(connections::is_connected(&server.server_id).await); connections::disconnect(&server.server_id).await; } #[tokio::test] async fn supervisor_skips_a_disabled_server() { use openhuman_core::openhuman::mcp_registry::supervisor; let (_tmp, cfg) = fresh_workspace_config(); let mut server = make_installed_server(); server.enabled = false; store::insert_server(&cfg, &server).expect("insert installed server"); // A disabled server must never be connected by the supervisor. supervisor::run_single_tick_for_test(&cfg).await; assert!( !connections::is_connected(&server.server_id).await, "supervisor does not connect disabled servers" ); }