Skip to content

fix(federation): preserve backend pagination in federated list responses - #71

Merged
lucarlig merged 7 commits into
mainfrom
fix/issue-5273
Jul 30, 2026
Merged

fix(federation): preserve backend pagination in federated list responses#71
lucarlig merged 7 commits into
mainfrom
fix/issue-5273

Conversation

@Lang-Akshay

@Lang-Akshay Lang-Akshay commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Closes IBM/mcp-context-forge#5273

The federated list handlers (tools/list, resources/list, resources/templates/list, prompts/list) had three compounding bugs:

  1. Cursor misrouting — the raw incoming cursor was broadcast verbatim to every backend. A cursor is backend-specific; sending it to the wrong backends caused silent failures or incorrect results.
  2. Cursor suppressionmerge_tools (and the other three merge functions) always returned with_all_items(…) which hardwires next_cursor: None, silently dropping any pagination information the backends returned. Items past a backend's first page were permanently unreachable.
  3. Request metadata dropped — the new per-backend closures constructed a fresh PaginatedRequestParams::default().with_cursor(…), discarding any _meta fields the client included in the original request. The fix clones the incoming request and replaces only the cursor field, so all other params survive to each backend.

Solution

Gateway cursor format — the gateway wraps per-backend positions into a single opaque token for the MCP client:

raw JSON  →  { "backends": { "<backend-id>": "<backend-cursor>", … } }

Only backends that still have pages appear in the map; absent = exhausted. The token is opaque to clients (they must not parse it per MCP spec §Pagination).

Federated pagination algorithm:

Request Backends queried Each backend receives
First (no cursor) All Original request unchanged (first page)
Resume (cursor present) Only those in cursor map Clone of original request with cursor replaced by per-backend cursor

Items from each round are merged and sorted as before. The gateway emits a next_cursor whenever at least one backend still has pages; it is omitted once all are exhausted. An undecodable cursor returns −32602 Invalid params (MCP spec requirement).

Known limitation (documented in book): if backend topology changes between pages, the cursor for a removed backend is silently dropped. A cursor version field would fix this; deferred until needed.

Changes

File What
list_aggregation.rs GatewayCursor struct + decode_gateway_cursor + encode_next_cursor; fan_out_list closure gains backend name param; all four merge_* functions return (Vec<Item>, Option<String>)
mcp_service/tools.rs Decode cursor, filter exhausted backends on resume; clone original request and replace only cursor per backend (preserves _meta); set next_cursor on result
mcp_service/resources.rs Same for list_resources + list_resource_templates
mcp_service/prompts.rs Same for list_prompts
tests/support/paginating_mock.rs New PaginatingServer — minimal ServerHandler returning tools across 2 pages
tests/support/mod.rs Export paginating_mock
tests/gateway_pagination.rs Two integration tests; backend ports are bound before tokio::spawn (via bind_backend_port) to eliminate startup race
docs/book/src/mcp-routing-semantics.md New Federated Pagination section; removes stale Known Gap entry

No new crate dependencies — serde_json (already available) encodes the cursor.

Testing

New unit tests (list_aggregation.rs):

  • backend_next_cursor_is_preserved_in_gateway_cursor — a backend next_cursor survives the round-trip through encode/decode
  • exhausted_backends_produce_no_next_cursornext_cursor: None from backend → gateway omits cursor
  • invalid_cursor_returns_invalid_params_error — malformed cursor → −32602
  • none_cursor_returns_default_empty_gateway_cursorNone input → first-page default

New integration tests (tests/gateway_pagination.rs):

  • single_backend_pagination_all_tools_reachable — backend returns 2 pages; gateway client retrieves all 3 tools across 2 list_tools calls with no item dropped
  • multi_backend_exhausted_backend_not_requeried — two paginating backends; page 2 has exactly 2 tools (1 from each backend's second page), proving no re-query of exhausted backends; deduplication check confirms no items appear twice

Backend readiness: each mock binds its TCP port synchronously (before tokio::spawn) so the port is guaranteed reserved before connect_client is called.

Existing tests — zero regressions:

  • gateway_list_tools (2 tests) ✓
  • gateway_prompts (2 tests) ✓
  • gateway_resource_templates (2 tests) ✓
  • All 28 lib unit tests ✓

Out of scope (per issue)

CPEX/plugin hook integration, tool/resource/prompt filtering, header passthrough rules, control-plane changes.

@Lang-Akshay Lang-Akshay changed the title fix(federation): preserve backend pagination in federated list responses (#5273) fix(federation): preserve backend pagination in federated list responses Jul 30, 2026
@lucarlig
lucarlig self-requested a review July 30, 2026 13:07

@lucarlig lucarlig left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lang-Akshay I reviewed head 2537394c. The per-backend cursor approach is sound, but please address these before merge:

  1. Do not turn backend failures into exhaustion. fan_out_list drops failed/unavailable backends, while the new cursor model treats absence as exhausted. If a backend times out during a resumed page, its incoming cursor is erased and the client can never reach its remaining items. Preserve that backend's prior cursor or fail the list request.

  2. Exercise the target MCP contract. The new fixture advertises V_2024_11_05, and the integration test uses legacy initialization plus requests without the required per-request metadata. New protocol tests in this repository must target 2026-07-28, use server/discover, and include client metadata if available.

  3. Scope gateway cursors to the list operation. The token currently contains only backend positions, so a cursor returned by tools/list is accepted by prompts/list or resources/list and forwarded upstream. Add and validate an operation discriminator so cross-operation cursors return -32602.

  4. Strengthen the multi-backend regression test. Both mocks use the same "page2" cursor and exhaust simultaneously. Broadcasting one raw cursor to every backend would therefore still pass, and the test never resumes while only one backend is exhausted. Use backend-specific cursors, uneven page counts, and call counters.

  5. Run cargo fmt --all. The current fmt check fails in prompts.rs, resources.rs, and tools.rs.

Verification: focused pagination tests pass locally (2 integration + 5 unit). GitHub build, Clippy, tests, cargo-deny, and benchmarks pass; fmt is the remaining failing check.

…e with mcp specs

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
…casrries no operation discriminator

Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
@Lang-Akshay

Copy link
Copy Markdown
Contributor Author

Fix: cursor exhaustion on backend failure + cross-operation cursor leakage

Two bugs in the gateway's paginated list cursor model, both in list_aggregation.rs.


Finding 1 — Backend timeout during resume treated as exhaustion

Root cause.
fan_out_list drops any backend that errors or times out (the Some(Ok(_)) => …, _ => None filter). The merge functions only saw successful responses, so a backend that was mid-pagination but failed on the current page simply vanished from next_backends. The outgoing cursor carried no record of it. On the next page the filter contains_key(&b.name) excluded it permanently. Its remaining items were unreachable for the rest of the pagination session.

Fix.
Each merge function (merge_tools, merge_resources, merge_resource_templates, merge_prompts) now receives the incoming GatewayCursor. After collecting next_cursor values from backends that succeeded, it iterates over incoming_cursor.backends and re-emits the prior cursor position for any backend that was expected to continue (present in the incoming cursor) but did not appear in the success set. Backends that truly exhausted themselves returned next_cursor = None, so they never entered incoming_cursor.backends in the first place — the two cases are unambiguous.

// Preserve cursor for backends that were expected to continue but failed this page.
for (backend_name, prior_cursor) in &incoming_cursor.backends {
    if !succeeded.contains(backend_name.as_str()) {
        next_backends.entry(backend_name.clone()).or_insert_with(|| prior_cursor.clone());
    }
}

Finding 2 — No operation discriminator on GatewayCursor

Root cause.
GatewayCursor was { backends: HashMap<String, String> } with no field identifying which list operation issued it. decode_gateway_cursor was a single shared function called by all four handlers. A cursor returned by tools/list deserialized cleanly when passed to prompts/list, and the backend-level cursor value was forwarded upstream to a completely different operation.

Fix.
Added op: String to GatewayCursor. decode_gateway_cursor now takes expected_op: &str and returns -32602 Invalid params with message "cursor operation mismatch" when cursor.op != expected_op. encode_next_cursor stamps the operation name into every cursor it emits. Each handler passes its own stable operation string:

Handler op string
list_tools "list_tools"
list_resources "list_resources"
list_resource_templates "list_resource_templates"
list_prompts "list_prompts"

Because the cursor is opaque and this project has no external users, the format change is not backward-compatible — existing in-flight cursors will be rejected as invalid, which is the correct safe-failure mode.


Tests added

Test Covers
failed_backend_cursor_preserved_across_page Backend absent from success set keeps its prior cursor in the outgoing token
cross_operation_cursor_rejected A list_tools cursor passed to decode_gateway_cursor(..., "list_prompts") returns INVALID_PARAMS

Existing tests updated for the new signatures. All 57 tests pass, clippy clean.

@lucarlig lucarlig left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@lucarlig
lucarlig merged commit ba44692 into main Jul 30, 2026
9 checks passed

@gandhipratik203 gandhipratik203 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing worth a look: the cursor may never terminate. list_aggregation.rs:138-143 (and 190, 240, 289) carries a cursor forward for any backend not in succeeded, but fan_out_list can't distinguish "failed" from "never attempted". A backend removed from the VH gets filtered out at tools.rs:33-37, so it re-emits its cursor indefinitely. Might be worth handling those two cases separately?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CF-DATAPLANE] Preserve pagination when federating list responses

3 participants