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
299 lines
11 KiB
Rust
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();
|
|
}
|