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:
2026-08-29 10:54:33 +00:00
parent 314472012d
commit 8700ed0fea
7 changed files with 837 additions and 107 deletions
+218 -7
View File
@@ -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"),
}
}