mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
379 lines
14 KiB
Rust
379 lines
14 KiB
Rust
//! Regression tests for multi-MCP-server support (#3196).
|
|
//!
|
|
//! These verify the three risks the issue calls out:
|
|
//! 1. Two servers exposing identically-named tools both reach the agent
|
|
//! surface without collision (namespaced tool_id keys via server_id).
|
|
//! 2. One server failing to connect does not block another from running.
|
|
//! 3. Tool listing aggregates across all connected servers and tolerates
|
|
//! a missing one.
|
|
//!
|
|
//! All tests are hermetic — they use the `test-mcp-stub` binary built
|
|
//! by Cargo (exposed via `CARGO_BIN_EXE_test-mcp-stub`) and a fresh
|
|
//! temp-dir workspace so they do not share SQLite state with each other
|
|
//! or with `mcp_registry_e2e.rs`.
|
|
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::mcp_registry::types::{CommandKind, InstalledServer, Transport};
|
|
use openhuman_core::openhuman::mcp_registry::{connections, ops, store};
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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_stub_server(qualified: &str) -> InstalledServer {
|
|
let stub = env!("CARGO_BIN_EXE_test-mcp-stub");
|
|
InstalledServer {
|
|
server_id: format!("multi-{}", uuid::Uuid::new_v4()),
|
|
qualified_name: qualified.to_string(),
|
|
display_name: qualified.to_string(),
|
|
description: None,
|
|
icon_url: None,
|
|
command_kind: CommandKind::Binary,
|
|
command: stub.to_string(),
|
|
args: Vec::new(),
|
|
env_keys: Vec::new(),
|
|
config: None,
|
|
installed_at: 0,
|
|
last_connected_at: None,
|
|
transport: Transport::Stdio,
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
// ── extract response text (mirrors mcp_registry_e2e.rs convention) ───────────
|
|
|
|
fn extract_echo_text(result: &serde_json::Value) -> &str {
|
|
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("")
|
|
}
|
|
|
|
// ── Test 1: name collision ────────────────────────────────────────────────────
|
|
|
|
/// Two stub servers both expose the same tool name (`echo`). After connecting
|
|
/// both, `all_connected_tools` returns two entries — one per server — and both
|
|
/// respond correctly when called by `server_id`, proving the `server_id` is
|
|
/// the disambiguator for identically-named tools.
|
|
#[tokio::test]
|
|
async fn two_servers_same_tool_name_no_collision() {
|
|
let (_tmp, cfg) = fresh_workspace_config();
|
|
|
|
let server_a = make_stub_server("@multi-test/echo-a");
|
|
let server_b = make_stub_server("@multi-test/echo-b");
|
|
|
|
// Both server_ids must be distinct (sanity guard for the disambiguation
|
|
// assertions below).
|
|
assert_ne!(
|
|
server_a.server_id, server_b.server_id,
|
|
"two stub servers must have distinct server_ids"
|
|
);
|
|
|
|
store::insert_server(&cfg, &server_a).expect("insert server_a");
|
|
store::insert_server(&cfg, &server_b).expect("insert server_b");
|
|
|
|
// Connect both — each spawns its own subprocess.
|
|
let tools_a = connections::connect(&cfg, &server_a)
|
|
.await
|
|
.expect("connect server_a");
|
|
let tools_b = connections::connect(&cfg, &server_b)
|
|
.await
|
|
.expect("connect server_b");
|
|
|
|
assert_eq!(tools_a.len(), 1, "server_a: stub advertises one tool");
|
|
assert_eq!(tools_b.len(), 1, "server_b: stub advertises one tool");
|
|
assert_eq!(tools_a[0].name, "echo", "server_a tool name is `echo`");
|
|
assert_eq!(tools_b[0].name, "echo", "server_b tool name is `echo`");
|
|
|
|
// Both servers are in the connected aggregate.
|
|
let all_tools = connections::all_connected_tools().await;
|
|
let a_tools: Vec<_> = all_tools
|
|
.iter()
|
|
.filter(|(sid, _, _)| sid == &server_a.server_id)
|
|
.collect();
|
|
let b_tools: Vec<_> = all_tools
|
|
.iter()
|
|
.filter(|(sid, _, _)| sid == &server_b.server_id)
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
a_tools.len(),
|
|
1,
|
|
"server_a contributes exactly one tool to the aggregate"
|
|
);
|
|
assert_eq!(
|
|
b_tools.len(),
|
|
1,
|
|
"server_b contributes exactly one tool to the aggregate"
|
|
);
|
|
assert_eq!(
|
|
a_tools[0].2.name, "echo",
|
|
"server_a's aggregated tool is `echo`"
|
|
);
|
|
assert_eq!(
|
|
b_tools[0].2.name, "echo",
|
|
"server_b's aggregated tool is `echo`"
|
|
);
|
|
|
|
// Both are `connected` in all_status.
|
|
let statuses = connections::all_status(&cfg).await;
|
|
let find = |id: &str| {
|
|
statuses
|
|
.iter()
|
|
.find(|s| s.server_id == id)
|
|
.cloned()
|
|
.unwrap_or_else(|| panic!("status entry missing for {id}"))
|
|
};
|
|
assert_eq!(find(&server_a.server_id).status.as_str(), "connected");
|
|
assert_eq!(find(&server_a.server_id).tool_count, 1);
|
|
assert_eq!(find(&server_b.server_id).status.as_str(), "connected");
|
|
assert_eq!(find(&server_b.server_id).tool_count, 1);
|
|
|
|
let _ = connections::disconnect(&server_a.server_id).await;
|
|
let _ = connections::disconnect(&server_b.server_id).await;
|
|
}
|
|
|
|
// ── Test 2: routing ───────────────────────────────────────────────────────────
|
|
|
|
/// `call_tool` routes by `server_id` even with a shared name — each server
|
|
/// subprocess receives and echoes back the exact payload sent to that server_id.
|
|
/// Demonstrates the agent → core call path works across multiple servers without
|
|
/// cross-wiring.
|
|
#[tokio::test]
|
|
async fn tool_calls_route_to_the_correct_server() {
|
|
let (_tmp, cfg) = fresh_workspace_config();
|
|
|
|
let server_a = make_stub_server("@route-test/echo-a");
|
|
let server_b = make_stub_server("@route-test/echo-b");
|
|
|
|
store::insert_server(&cfg, &server_a).expect("insert server_a");
|
|
store::insert_server(&cfg, &server_b).expect("insert server_b");
|
|
|
|
connections::connect(&cfg, &server_a)
|
|
.await
|
|
.expect("connect server_a");
|
|
connections::connect(&cfg, &server_b)
|
|
.await
|
|
.expect("connect server_b");
|
|
|
|
// Send distinct payloads to each server; verify each echoes its own input.
|
|
let result_a = connections::call_tool(
|
|
&server_a.server_id,
|
|
"echo",
|
|
serde_json::json!({ "message": "payload-for-a" }),
|
|
)
|
|
.await
|
|
.expect("call_tool on server_a should succeed");
|
|
|
|
let result_b = connections::call_tool(
|
|
&server_b.server_id,
|
|
"echo",
|
|
serde_json::json!({ "message": "payload-for-b" }),
|
|
)
|
|
.await
|
|
.expect("call_tool on server_b should succeed");
|
|
|
|
let text_a = extract_echo_text(&result_a);
|
|
let text_b = extract_echo_text(&result_b);
|
|
|
|
assert_eq!(
|
|
text_a, "payload-for-a",
|
|
"server_a must echo back exactly what was sent to it"
|
|
);
|
|
assert_eq!(
|
|
text_b, "payload-for-b",
|
|
"server_b must echo back exactly what was sent to it"
|
|
);
|
|
|
|
// Cross-verify: calling server_b's id echoes server_b's payload — not server_a's.
|
|
assert_ne!(
|
|
text_a, text_b,
|
|
"two servers must not produce identical outputs for different inputs"
|
|
);
|
|
|
|
let _ = connections::disconnect(&server_a.server_id).await;
|
|
let _ = connections::disconnect(&server_b.server_id).await;
|
|
}
|
|
|
|
// ── Test 3: failure isolation ─────────────────────────────────────────────────
|
|
|
|
/// One server failing to connect must not prevent another from connecting or
|
|
/// being usable in the same session. The bad server records an `error` status
|
|
/// with a `last_error` message; the good server is `connected` and responds.
|
|
#[tokio::test]
|
|
async fn failed_connect_does_not_block_healthy_peer() {
|
|
let (_tmp, cfg) = fresh_workspace_config();
|
|
|
|
// "bad" server: non-existent binary.
|
|
let mut bad = make_stub_server("@isolation-test/bad");
|
|
bad.command = "/this/path/does/not/exist/bad-mcp".to_string();
|
|
|
|
// "good" server: real stub.
|
|
let good = make_stub_server("@isolation-test/good");
|
|
|
|
store::insert_server(&cfg, &bad).expect("insert bad server");
|
|
store::insert_server(&cfg, &good).expect("insert good server");
|
|
|
|
// Connecting the bad server must fail.
|
|
let connect_err = connections::connect(&cfg, &bad)
|
|
.await
|
|
.expect_err("connect bad server should fail");
|
|
assert!(
|
|
!connect_err.to_string().is_empty(),
|
|
"bad connect error must not be empty"
|
|
);
|
|
|
|
// Connecting the good server must succeed independently.
|
|
let good_tools = connections::connect(&cfg, &good)
|
|
.await
|
|
.expect("connect good server must succeed regardless of the bad peer");
|
|
assert_eq!(good_tools.len(), 1, "good server exposes one tool");
|
|
|
|
// all_status: bad → error with last_error, good → connected.
|
|
let statuses = connections::all_status(&cfg).await;
|
|
let find = |id: &str| {
|
|
statuses
|
|
.iter()
|
|
.find(|s| s.server_id == id)
|
|
.cloned()
|
|
.unwrap_or_else(|| panic!("status entry missing for {id}"))
|
|
};
|
|
|
|
let bad_status = find(&bad.server_id);
|
|
assert_eq!(
|
|
bad_status.status.as_str(),
|
|
"error",
|
|
"bad server must report `error`"
|
|
);
|
|
assert!(
|
|
bad_status
|
|
.last_error
|
|
.as_deref()
|
|
.map(|s| !s.is_empty())
|
|
.unwrap_or(false),
|
|
"bad server must have a non-empty last_error"
|
|
);
|
|
|
|
let good_status = find(&good.server_id);
|
|
assert_eq!(
|
|
good_status.status.as_str(),
|
|
"connected",
|
|
"good server must be `connected`"
|
|
);
|
|
assert!(
|
|
good_status.last_error.is_none(),
|
|
"good server must have no last_error"
|
|
);
|
|
|
|
// The good server is still callable after the peer's failure.
|
|
let result = connections::call_tool(
|
|
&good.server_id,
|
|
"echo",
|
|
serde_json::json!({ "message": "isolation-check" }),
|
|
)
|
|
.await
|
|
.expect("call_tool on good server must succeed after peer failure");
|
|
|
|
assert_eq!(
|
|
extract_echo_text(&result),
|
|
"isolation-check",
|
|
"good server must echo input correctly after peer failure"
|
|
);
|
|
|
|
let _ = connections::disconnect(&good.server_id).await;
|
|
}
|
|
|
|
// ── Test 4: disabled enforcement ──────────────────────────────────────────────
|
|
|
|
/// A disabled server must not contribute tools to the agent surface and must
|
|
/// refuse explicit connect, even when a sibling is connected and active.
|
|
#[tokio::test]
|
|
async fn disabled_server_contributes_no_tools_to_agent_surface() {
|
|
let (_tmp, cfg) = fresh_workspace_config();
|
|
|
|
// "live" server: enabled, real stub.
|
|
let live = make_stub_server("@disabled-test/live");
|
|
|
|
// "quiet" server: disabled, also uses the real stub binary (so the
|
|
// bogus-command failure path cannot mask the disabled enforcement check).
|
|
let mut quiet = make_stub_server("@disabled-test/quiet");
|
|
quiet.enabled = false;
|
|
|
|
store::insert_server(&cfg, &live).expect("insert live server");
|
|
store::insert_server(&cfg, &quiet).expect("insert quiet server");
|
|
|
|
// Connect the live server.
|
|
connections::connect(&cfg, &live)
|
|
.await
|
|
.expect("connect live server");
|
|
|
|
// Attempting to connect the disabled server via ops must fail with
|
|
// a clear "disabled" message (matches mcp_registry_e2e::connect_refuses_disabled_server).
|
|
let disabled_err = ops::mcp_clients_connect(&cfg, quiet.server_id.clone())
|
|
.await
|
|
.expect_err("connect must reject a disabled server");
|
|
assert!(
|
|
disabled_err.to_lowercase().contains("disabled"),
|
|
"error must mention 'disabled', got: {disabled_err}"
|
|
);
|
|
|
|
// all_status: live → connected, quiet → disabled.
|
|
let statuses = connections::all_status(&cfg).await;
|
|
let find = |id: &str| {
|
|
statuses
|
|
.iter()
|
|
.find(|s| s.server_id == id)
|
|
.cloned()
|
|
.unwrap_or_else(|| panic!("status entry missing for {id}"))
|
|
};
|
|
|
|
assert_eq!(find(&live.server_id).status.as_str(), "connected");
|
|
assert_eq!(find(&quiet.server_id).status.as_str(), "disabled");
|
|
|
|
// The disabled server must not appear in all_connected_tools.
|
|
let all_tools = connections::all_connected_tools().await;
|
|
let quiet_tools: Vec<_> = all_tools
|
|
.iter()
|
|
.filter(|(sid, _, _)| sid == &quiet.server_id)
|
|
.collect();
|
|
assert!(
|
|
quiet_tools.is_empty(),
|
|
"disabled server must contribute zero tools to the agent surface; found: {quiet_tools:?}"
|
|
);
|
|
|
|
// The live server IS present and callable.
|
|
let live_tools: Vec<_> = all_tools
|
|
.iter()
|
|
.filter(|(sid, _, _)| sid == &live.server_id)
|
|
.collect();
|
|
assert_eq!(
|
|
live_tools.len(),
|
|
1,
|
|
"live server must contribute its tool even while disabled peer is present"
|
|
);
|
|
|
|
let result = connections::call_tool(
|
|
&live.server_id,
|
|
"echo",
|
|
serde_json::json!({ "message": "live-while-quiet" }),
|
|
)
|
|
.await
|
|
.expect("call_tool on live server must succeed");
|
|
|
|
assert_eq!(
|
|
extract_echo_text(&result),
|
|
"live-while-quiet",
|
|
"live server echoes correctly while disabled peer exists"
|
|
);
|
|
|
|
let _ = connections::disconnect(&live.server_id).await;
|
|
}
|