Files
yvgude--lean-ctx/rust/tests/gateway_e2e.rs
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

299 lines
11 KiB
Rust

//! End-to-end test for the MCP Tool-Catalog Gateway client (#210).
//!
//! Two layers, no mocks:
//! 1. In-process: a *real* rmcp MCP server over a Tokio duplex pipe, exercising
//! the MCP protocol (`initialize` → `tools/list` → `tools/call`) for
//! determinism.
//! 2. Real stdio (#1077): spawns an actual child process (a Node.js MCP fixture)
//! through the gateway's production `open`/`fetch_tools`/`proxy_call` path —
//! the spawn + handshake + transport the in-process test cannot cover — and
//! asserts the [`pool`] reuses one session across calls (#1078). Skips
//! cleanly when `node` is unavailable.
use std::collections::BTreeMap;
use std::future::Future;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ContentBlock, ListToolsResult, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::RequestContext;
use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt};
use serde_json::json;
use serial_test::serial;
use lean_ctx::core::mcp_catalog::{ResolvedTransport, client, pool};
/// Minimal downstream MCP server exposing two tools: `echo` and `add`.
struct EchoServer;
impl ServerHandler for EchoServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, ErrorData>> {
let echo = Tool::new(
"echo",
"Echo back the provided text",
Arc::new(
json!({
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
})
.as_object()
.unwrap()
.clone(),
),
);
let add = Tool::new(
"add",
"Add two integers a and b and return the sum",
Arc::new(
json!({
"type": "object",
"properties": {
"a": { "type": "integer" },
"b": { "type": "integer" }
},
"required": ["a", "b"]
})
.as_object()
.unwrap()
.clone(),
),
);
std::future::ready(Ok(ListToolsResult {
tools: vec![echo, add],
..Default::default()
}))
}
fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResult, ErrorData>> {
let args = request.arguments.unwrap_or_default();
std::future::ready(match request.name.as_ref() {
"echo" => {
let text = args.get("text").and_then(|v| v.as_str()).unwrap_or("");
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"echo:{text}"
))]))
}
"add" => {
let a = args
.get("a")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let b = args
.get("b")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Ok(CallToolResult::success(vec![ContentBlock::text(
(a + b).to_string(),
)]))
}
other => Err(ErrorData::invalid_params(
format!("unknown tool: {other}"),
None,
)),
})
}
}
/// Connect a gateway client to an in-process `EchoServer` and return the
/// running client session.
async fn connect_to_echo_server() -> client::ClientService {
let (server_transport, client_transport) = tokio::io::duplex(8192);
tokio::spawn(async move {
if let Ok(server) = EchoServer.serve(server_transport).await {
let _ = server.waiting().await;
}
});
().serve(client_transport)
.await
.expect("client initialize handshake")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gateway_client_lists_downstream_tools() {
let service = connect_to_echo_server().await;
let timeout = Duration::from_secs(5);
let tools = client::list_tools_on(&service, timeout)
.await
.expect("list tools");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"echo"), "expected echo tool, got {names:?}");
assert!(names.contains(&"add"), "expected add tool, got {names:?}");
let _ = service.cancel().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gateway_client_proxies_a_call() {
let service = connect_to_echo_server().await;
let timeout = Duration::from_secs(5);
let mut args = serde_json::Map::new();
args.insert("a".into(), json!(2));
args.insert("b".into(), json!(3));
let result = client::call_tool_on(&service, "add", args, timeout)
.await
.expect("call add");
assert_eq!(client::result_to_text(&result).trim(), "5");
let mut echo_args = serde_json::Map::new();
echo_args.insert("text".into(), json!("hello"));
let echoed = client::call_tool_on(&service, "echo", echo_args, timeout)
.await
.expect("call echo");
assert_eq!(client::result_to_text(&echoed).trim(), "echo:hello");
let _ = service.cancel().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gateway_client_reports_unknown_tool_error() {
let service = connect_to_echo_server().await;
let timeout = Duration::from_secs(5);
// The downstream returns a protocol error for an unknown tool; our client
// surfaces it as an Err rather than panicking.
let res =
client::call_tool_on(&service, "does_not_exist", serde_json::Map::new(), timeout).await;
assert!(res.is_err(), "unknown tool should error, got {res:?}");
let _ = service.cancel().await;
}
/// Whether a `node` runtime is on PATH, so the real-stdio test can skip cleanly
/// in environments without it (the test asserts nothing when Node is absent).
fn node_available() -> bool {
Command::new("node")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
/// Resolved transport that spawns the Node.js MCP fixture over real stdio.
fn fixture_transport() -> ResolvedTransport {
let fixture = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/mcp_stdio_echo.mjs"
);
ResolvedTransport::Stdio {
command: "node".into(),
args: vec![fixture.to_string()],
env: BTreeMap::new(),
binary_sha256: String::new(),
capabilities: None,
}
}
/// #1077 + #1078: drive the *real* stdio spawn path end to end — spawn a child
/// process, run the MCP handshake, `tools/list`, then `tools/call` — and confirm
/// the session pool reuses one live child across both operations instead of
/// respawning per call.
// Serialized: both real-stdio tests drive the *global* session `pool` against
// the *same* fixture wiring (same key) using `clear`/`len`, so the default
// concurrent harness would let one test's reset corrupt the other's session.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(gateway_stdio_pool)]
async fn gateway_spawns_real_stdio_server_and_reuses_one_pooled_session() {
if !node_available() {
eprintln!("skipping gateway stdio E2E: `node` is not available on PATH");
return;
}
pool::clear();
let transport = fixture_transport();
let timeout = Duration::from_secs(15);
// Real spawn → initialize → tools/list.
let tools = client::fetch_tools(&transport, timeout)
.await
.expect("fetch_tools over real stdio");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"echo"), "expected echo tool, got {names:?}");
// Real proxy_call over the same pooled session.
let mut args = serde_json::Map::new();
args.insert("text".into(), json!("hi"));
let result = client::proxy_call(&transport, "echo", args, timeout)
.await
.expect("proxy_call over real stdio");
assert_eq!(client::result_to_text(&result).trim(), "echo:hi");
// The list + the call shared a single pooled child (no per-call respawn).
assert_eq!(
pool::len(),
1,
"expected exactly one pooled session reused across list + call"
);
// Tear down: closes the child's stdin so the fixture exits.
pool::clear();
}
/// #1078: when a pooled child dies mid-call, the call surfaces an error *once*
/// (no blind retry — a tool may be non-idempotent), the broken session is
/// evicted, and the very next call transparently reopens a fresh child. Drives
/// the real stdio path with a fault-injecting `boom` tool that exits without
/// replying.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(gateway_stdio_pool)]
async fn gateway_pool_evicts_a_dead_session_and_reopens_on_next_call() {
if !node_available() {
eprintln!("skipping gateway stdio self-heal E2E: `node` is not available on PATH");
return;
}
pool::clear();
let transport = fixture_transport();
let timeout = Duration::from_secs(15);
// Warm one pooled session.
let _ = client::fetch_tools(&transport, timeout)
.await
.expect("warm the pool");
assert_eq!(pool::len(), 1, "one warmed session");
// `boom` kills the child without answering: the call must fail (not hang,
// not loop) and the dead session must be evicted rather than reused.
let boom = client::proxy_call(&transport, "boom", serde_json::Map::new(), timeout).await;
assert!(
boom.is_err(),
"a call to a dying child must surface an error"
);
assert_eq!(
pool::len(),
0,
"the broken session is evicted, not retained"
);
// The next call reopens a fresh child and succeeds — self-healing.
let mut args = serde_json::Map::new();
args.insert("text".into(), json!("back"));
let ok = client::proxy_call(&transport, "echo", args, timeout)
.await
.expect("next call reopens a fresh session");
assert_eq!(client::result_to_text(&ok).trim(), "echo:back");
assert_eq!(pool::len(), 1, "exactly one fresh session after reopen");
pool::clear();
}