282 lines
9.3 KiB
Rust
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);
|
|
}
|