fix(adapters): consumer adapter hygiene (CON-01, CON-03..CON-13)
- CON-01: from_mcp discovery follows tools/list pagination (rmcp list_all_tools); three-page paginating-server test - CON-03: from_wss refuses ws:// with a Bearer token unless FromWss::allow_plaintext() is called explicitly (tests: refusal, opt-in, token-less passthrough) - CON-04: audio variant of content_block_union_schema requires ["type","data","mimeType"]; jsonschema-validated audio block - CON-05/07: import-time credential documented on both adapters; dead per-call capability read removed - CON-06: 401 classification typed-first (downcast to rmcp StreamableHttpError<reqwest::Error>; AuthRequired/InsufficientScope/ Client with status 401); a :40101 URL no longer misclassifies (tested) - CON-11: transport tools/call failures declare MCP_TRANSPORT_ERROR; rmcp JSON-RPC errors preserve code (MCP_JRPC_<code>) and data - CON-12: tool names validated at import (/, whitespace, empty → SchemaParse); unit + integration tests - CON-13: tokens held as alkcall Secret<String> (zeroize, redacted Debug) - CON-08/09: no close handles; explicit-limitation notes in from_mcp module docs, from_wss module docs, and ADR-070 - CON-10: full_surface [[test]] required-features = ["mcp","test-support"]; cargo test --features mcp now compiles and passes Verified: cargo test; cargo test --features mcp; cargo test --all-features; cargo clippy (--all-features) --all-targets -- -D warnings; cargo fmt --check
This commit is contained in:
@@ -122,13 +122,60 @@ impl ServerHandler for EchoServer {
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||
let mcp_service: StreamableHttpService<EchoServer, LocalSessionManager> =
|
||||
StreamableHttpService::new(
|
||||
|| Ok(EchoServer),
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
/// A paginating `tools/list` server (CON-01): three pages behind a
|
||||
/// cursor chain; the importer must follow `next_cursor` to see all tools.
|
||||
struct PagingServer;
|
||||
|
||||
impl ServerHandler for PagingServer {
|
||||
fn list_tools(
|
||||
&self,
|
||||
request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>>
|
||||
+ rmcp::service::MaybeSendFuture
|
||||
+ '_ {
|
||||
let cursor = request.and_then(|p| p.cursor);
|
||||
let page_tools: Vec<(&str, &str)> = match cursor.as_deref() {
|
||||
None => vec![("p1_a", "page 1"), ("p1_b", "page 1")],
|
||||
Some("page1") => vec![("p2_a", "page 2")],
|
||||
Some("page2") => vec![("p3_a", "page 3")],
|
||||
Some(_) => vec![],
|
||||
};
|
||||
let next_cursor = match cursor.as_deref() {
|
||||
None => Some("page1".to_string()),
|
||||
Some("page1") => Some("page2".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let tools = page_tools
|
||||
.into_iter()
|
||||
.map(|(name, desc)| {
|
||||
Tool::new_with_raw(
|
||||
name.to_string(),
|
||||
Some(desc.into()),
|
||||
Arc::new(serde_json::Map::new()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::future::ready(Ok(ListToolsResult {
|
||||
meta: None,
|
||||
next_cursor,
|
||||
tools,
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> rmcp::model::ServerInfo {
|
||||
rmcp::model::ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_server_for<S: ServerHandler + 'static>(
|
||||
server: impl Fn() -> Result<S, std::io::Error> + Send + Sync + 'static,
|
||||
) -> (String, tokio::task::JoinHandle<()>) {
|
||||
let mcp_service: StreamableHttpService<S, LocalSessionManager> = StreamableHttpService::new(
|
||||
server,
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let app = Router::new().nest_service("/mcp", mcp_service);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
@@ -138,6 +185,10 @@ async fn spawn_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||
(format!("http://{addr}/mcp"), handle)
|
||||
}
|
||||
|
||||
async fn spawn_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||
spawn_server_for(|| Ok(EchoServer)).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_discovers_tools_and_builds_registrations() {
|
||||
let (endpoint, _handle) = spawn_server().await;
|
||||
@@ -247,3 +298,163 @@ async fn import_unreachable_server_returns_discovery_failed() {
|
||||
Err(other) => panic!("expected DiscoveryFailed or Transport, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_follows_tools_list_pagination() {
|
||||
let (endpoint, _handle) = spawn_server_for(|| Ok(PagingServer)).await;
|
||||
let adapter = FromMCP::new(endpoint, "pg");
|
||||
let bundles = adapter
|
||||
.import()
|
||||
.await
|
||||
.expect("import follows every tools/list page (CON-01)");
|
||||
let mut names: Vec<&str> = bundles.iter().map(|b| b.spec.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["pg/p1_a", "pg/p1_b", "pg/p2_a", "pg/p3_a"],
|
||||
"all three pages must be imported"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_refuses_tool_name_containing_slash() {
|
||||
struct SlashToolServer;
|
||||
|
||||
impl ServerHandler for SlashToolServer {
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>>
|
||||
+ rmcp::service::MaybeSendFuture
|
||||
+ '_ {
|
||||
let tools = vec![Tool::new_with_raw(
|
||||
"weird/tool",
|
||||
Some("a tool name with a slash".into()),
|
||||
Arc::new(serde_json::Map::new()),
|
||||
)];
|
||||
std::future::ready(Ok(ListToolsResult {
|
||||
meta: None,
|
||||
next_cursor: None,
|
||||
tools,
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> rmcp::model::ServerInfo {
|
||||
rmcp::model::ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
let (endpoint, _handle) = spawn_server_for(|| Ok(SlashToolServer)).await;
|
||||
let adapter = FromMCP::new(endpoint, "ns");
|
||||
match adapter.import().await {
|
||||
Ok(_) => panic!("expected Err for remote tool name containing `/`"),
|
||||
Err(alkcall::client::AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains('/'), "error names the offending tool");
|
||||
}
|
||||
Err(other) => panic!("expected SchemaParse, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarding_handler_maps_json_rpc_tool_error_with_code_fidelity() {
|
||||
// A server whose `call_tool` returns a JSON-RPC error (rmcp
|
||||
// `ErrorData`): the forwarding handler must surface the remote
|
||||
// error code (MCP_JRPC_<code>), not a flattened INTERNAL.
|
||||
struct JsonRpcErrorServer;
|
||||
|
||||
impl ServerHandler for JsonRpcErrorServer {
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>>
|
||||
+ rmcp::service::MaybeSendFuture
|
||||
+ '_ {
|
||||
let tools = vec![Tool::new_with_raw(
|
||||
"boom",
|
||||
Some("this tool always fails with a JSON-RPC error".into()),
|
||||
Arc::new(serde_json::Map::new()),
|
||||
)];
|
||||
std::future::ready(Ok(ListToolsResult {
|
||||
meta: None,
|
||||
next_cursor: None,
|
||||
tools,
|
||||
}))
|
||||
}
|
||||
|
||||
fn call_tool(
|
||||
&self,
|
||||
_request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl std::future::Future<Output = Result<CallToolResult, rmcp::ErrorData>>
|
||||
+ rmcp::service::MaybeSendFuture
|
||||
+ '_ {
|
||||
std::future::ready(Err(rmcp::ErrorData::resource_not_found(
|
||||
"tool not found on the remote server",
|
||||
Some(serde_json::json!({ "detail": "unknown-tool" })),
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> rmcp::model::ServerInfo {
|
||||
rmcp::model::ServerInfo::default()
|
||||
}
|
||||
}
|
||||
|
||||
let (endpoint, _handle) = spawn_server_for(|| Ok(JsonRpcErrorServer)).await;
|
||||
let adapter = FromMCP::new(endpoint, "echo");
|
||||
let bundles = adapter.import().await.expect("import succeeds");
|
||||
let any = bundles
|
||||
.into_iter()
|
||||
.find(|b| b.spec.name == "echo/boom")
|
||||
.expect("boom tool present");
|
||||
|
||||
let ctx = test_context("req-jrpc", Capabilities::new());
|
||||
let response = match &any.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
|
||||
HandlerKind::Stream(_) | HandlerKind::Sink(_) => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
e.code, "MCP_JRPC_-32002",
|
||||
"JSON-RPC error code preserved in the call-error code, got {e:?}"
|
||||
);
|
||||
assert_eq!(e.message, "tool not found on the remote server");
|
||||
let details = e.details.expect("JSON-RPC data preserved as details");
|
||||
assert_eq!(details["detail"], "unknown-tool");
|
||||
}
|
||||
Ok(_) => panic!("expected Err for the JSON-RPC error path"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_call_failure_maps_to_declared_mcp_transport_error() {
|
||||
// Import against a live server, kill it, then call: the in-flight
|
||||
// handler surfaces MCP_TRANSPORT_ERROR (declared), not INTERNAL.
|
||||
let (endpoint, handle) = spawn_server().await;
|
||||
let adapter = FromMCP::new(endpoint, "echo");
|
||||
let bundles = adapter.import().await.expect("import succeeds");
|
||||
let echo = bundles
|
||||
.into_iter()
|
||||
.find(|b| b.spec.name == "echo/echo")
|
||||
.expect("echo tool present");
|
||||
|
||||
handle.abort();
|
||||
|
||||
let ctx = test_context("req-transport", Capabilities::new());
|
||||
let response = match &echo.handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({ "x": 1 }), ctx).await,
|
||||
HandlerKind::Stream(_) | HandlerKind::Sink(_) => panic!("expected Once handler"),
|
||||
};
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
e.code, "MCP_TRANSPORT_ERROR",
|
||||
"declared transport failure mode (CON-11), got {e:?}"
|
||||
);
|
||||
assert!(e.retryable, "transport failure is retryable");
|
||||
}
|
||||
Ok(_) => panic!("expected Err after server shutdown"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user