Files
openhuman/tests/mcp_registry_e2e.rs
T

477 lines
17 KiB
Rust

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