Files
galaxy/crates/mcp/src/runtime_tests.rs
T

282 lines
9.3 KiB
Rust

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use rmcp::model::{ErrorCode, ErrorData, Resource, ServerCapabilities, Tool};
use super::{query_resources_for, query_tools_for, should_query_resources, should_query_tools};
/// Build a `ServerCapabilities` with selected capability flags toggled on.
/// Each `Some(default)` mirrors how rmcp deserializes a capability the
/// server advertised with no inner flags set.
fn caps(tools: bool, resources: bool) -> ServerCapabilities {
match (tools, resources) {
(true, true) => ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
(true, false) => ServerCapabilities::builder().enable_tools().build(),
(false, true) => ServerCapabilities::builder().enable_resources().build(),
(false, false) => ServerCapabilities::builder().build(),
}
}
fn test_tool(name: &str) -> Tool {
serde_json::from_value(serde_json::json!({
"name": name,
"description": "test tool",
"inputSchema": { "type": "object" },
}))
.expect("Tool deserialization")
}
fn test_resource(uri: &str) -> Resource {
serde_json::from_value(serde_json::json!({
"uri": uri,
"name": "test resource",
}))
.expect("Resource deserialization")
}
/// Regression test for warpdotdev/warp#6798: each capability is queried
/// independently. Previously, asymmetric handling could cause `tools/list`
/// to be skipped when a server advertised both `tools` and `resources`,
/// resulting in "No tools available" even though the server had tools.
#[test]
fn each_capability_is_queried_independently() {
for has_tools in [false, true] {
for has_resources in [false, true] {
let c = caps(has_tools, has_resources);
assert_eq!(
should_query_tools(Some(&c)),
has_tools,
"tools={has_tools}, resources={has_resources}",
);
assert_eq!(
should_query_resources(Some(&c)),
has_resources,
"tools={has_tools}, resources={has_resources}",
);
}
}
assert!(!should_query_tools(None));
assert!(!should_query_resources(None));
}
/// When `tools` is not advertised, the helper must skip the list call so
/// we don't waste a round trip and pollute the wire log with a request
/// that's destined to return `METHOD_NOT_FOUND`.
#[tokio::test]
async fn query_tools_for_skips_listing_when_capability_not_advertised() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let no_caps = caps(false, false);
let result = query_tools_for(Some(&no_caps), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("never")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Skips `tools/list` when server info is absent.
#[tokio::test]
async fn query_tools_for_skips_listing_when_server_info_is_none() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_tools_for(None, "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("never")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Returns listed tools when `tools` is advertised.
#[tokio::test]
async fn query_tools_for_returns_listed_tools_when_capability_advertised() {
let c = caps(true, false);
let expected = vec![test_tool("greet"), test_tool("review")];
let to_return = expected.clone();
let result = query_tools_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
assert_eq!(result, expected);
}
/// Returns an empty vector when the server lists no tools.
#[tokio::test]
async fn query_tools_for_returns_empty_vec_when_server_lists_no_tools() {
let c = caps(true, false);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_tools_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// **The fail-soft test the bug ticket implicitly demands.** Transport-
/// closed errors must not abort server startup; the helper must log and
/// return an empty vec. This is the regression-protector for #6798's
/// underlying asymmetry — if anyone re-introduces a `return Err(...)` here,
/// this test fails.
#[tokio::test]
async fn query_tools_for_returns_empty_on_transport_error() {
let c = caps(true, false);
let result = query_tools_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::TransportClosed)
})
.await;
assert!(result.is_empty());
}
/// MCP-protocol errors (e.g. METHOD_NOT_FOUND from a misbehaving server
/// that advertised the capability but rejects the call) also fail soft,
/// so the rest of the server surface still comes up.
#[tokio::test]
async fn query_tools_for_returns_empty_on_mcp_error() {
let c = caps(true, false);
let result = query_tools_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::McpError(ErrorData {
code: ErrorCode::METHOD_NOT_FOUND,
message: "tools/list not implemented".into(),
data: None,
}))
})
.await;
assert!(result.is_empty());
}
/// Calls the `tools/list` function exactly once per query.
#[tokio::test]
async fn query_tools_for_calls_list_function_exactly_once() {
let c = caps(true, false);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let _ = query_tools_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_tool("p")])
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
/// Keeps the tools-listing decision independent of resource capability state.
#[tokio::test]
async fn query_tools_for_decision_independent_of_other_capabilities() {
let tools = vec![test_tool("x")];
for has_tools in [false, true] {
for has_resources in [false, true] {
let c = caps(has_tools, has_resources);
let to_return = tools.clone();
let result = query_tools_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
if has_tools {
assert_eq!(result, tools);
} else {
assert!(result.is_empty());
}
}
}
}
/// Skips `resources/list` when `resources` is not advertised.
#[tokio::test]
async fn query_resources_for_skips_listing_when_capability_not_advertised() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let no_caps = caps(false, false);
let result = query_resources_for(Some(&no_caps), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///nope")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Skips `resources/list` when server info is absent.
#[tokio::test]
async fn query_resources_for_skips_listing_when_server_info_is_none() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let result = query_resources_for(None, "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///nope")])
})
.await;
assert!(result.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
/// Returns listed resources when `resources` is advertised.
#[tokio::test]
async fn query_resources_for_returns_listed_resources_when_capability_advertised() {
let c = caps(false, true);
let expected = vec![test_resource("file:///a"), test_resource("file:///b")];
let to_return = expected.clone();
let result = query_resources_for(Some(&c), "srv", || async move { Ok(to_return) }).await;
assert_eq!(result, expected);
}
/// Fails soft when `resources/list` sees a transport error.
#[tokio::test]
async fn query_resources_for_returns_empty_on_transport_error() {
let c = caps(false, true);
let result = query_resources_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::TransportClosed)
})
.await;
assert!(result.is_empty());
}
/// Fails soft when `resources/list` returns an MCP protocol error.
#[tokio::test]
async fn query_resources_for_returns_empty_on_mcp_error() {
let c = caps(false, true);
let result = query_resources_for(Some(&c), "srv", || async {
Err(rmcp::ServiceError::McpError(ErrorData {
code: ErrorCode::METHOD_NOT_FOUND,
message: "resources/list not implemented".into(),
data: None,
}))
})
.await;
assert!(result.is_empty());
}
/// Calls the `resources/list` function exactly once per query.
#[tokio::test]
async fn query_resources_for_calls_list_function_exactly_once() {
let c = caps(false, true);
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let _ = query_resources_for(Some(&c), "srv", || async move {
calls_clone.fetch_add(1, Ordering::SeqCst);
Ok(vec![test_resource("file:///a")])
})
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}