fix(adapters): bound from_mcp tools/list pagination (CON-14)

Replace rmcp's unbounded Peer::list_all_tools with a bounded walk over
list_tools: hard cap of 100 pages (MCP_MAX_TOOLS_LIST_PAGES) plus a 60 s
overall deadline (MCP_TOOLS_LIST_DEADLINE). Tripping either budget fails
loudly with AdapterError::DiscoveryFailed naming pages fetched and tools
accumulated — no silent truncation, no partial registration (import
fails closed, as before). A slow or hung page is cut off by a per-page
tokio timeout sized to the remaining budget.

Bounds are documented in the module doc. Integration test added: a
cycling-cursor server (next_cursor always Some("a")) terminates with
the clean budget error inside an outer 10 s guard; the existing 3-page
pagination test is unchanged and passes.

Verification: cargo test (304 pass), cargo test --features mcp,
cargo test --all-features (412 pass), clippy -D warnings both default
and --all-features --all-targets, cargo fmt --check, cargo doc --no-deps.
This commit is contained in:
2026-08-30 11:46:46 +00:00
parent e2c255d40c
commit 5ecf6c012b
2 changed files with 129 additions and 7 deletions
+64
View File
@@ -168,6 +168,37 @@ impl ServerHandler for PagingServer {
}
}
/// A hostile paging `tools/list` server (review-002 CON-14): always
/// responds with the same single tool and `next_cursor: Some("a")` — the
/// cursor cycles and never clears. The importer must bound the walk and
/// fail with a clean `DiscoveryFailed` rather than hang forever.
struct CyclingCursorServer;
impl ServerHandler for CyclingCursorServer {
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(
"hostile",
Some("served on every page, forever".into()),
Arc::new(serde_json::Map::new()),
)];
std::future::ready(Ok(ListToolsResult {
meta: None,
next_cursor: Some("a".to_string()),
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<()>) {
@@ -316,6 +347,39 @@ async fn import_follows_tools_list_pagination() {
);
}
#[tokio::test]
async fn import_cycling_cursor_fails_bounded_with_discovery_failed() {
let (endpoint, _handle) = spawn_server_for(|| Ok(CyclingCursorServer)).await;
let adapter = FromMCP::new(endpoint, "x");
let started = Instant::now();
let result = tokio::time::timeout(Duration::from_secs(10), adapter.import()).await;
let elapsed = started.elapsed();
let outcome = result.expect("import must terminate, not hang (CON-14)");
match outcome {
Ok(_) => panic!("expected Err for a cycling-cursor server"),
Err(alkcall::client::AdapterError::DiscoveryFailed { message }) => {
assert!(
message.contains("pagination exceeded budget"),
"error must name the budget, got: {message}"
);
assert!(
message.contains("page"),
"error must name pages fetched, got: {message}"
);
assert!(
message.contains("tool"),
"error must name tools accumulated, got: {message}"
);
}
Err(other) => panic!("expected DiscoveryFailed, got {other}"),
}
assert!(
elapsed < Duration::from_secs(10),
"bounded walk must trip the page cap well inside the outer guard, took {elapsed:?}"
);
}
#[tokio::test]
async fn import_refuses_tool_name_containing_slash() {
struct SlashToolServer;