Skip to content

fix(mcp): cap list-tool pagesize and strip auto-generated outputSchema - #149

Open
frederik-raphael wants to merge 3 commits into
developfrom
fix/mcp-pagesize-cap
Open

fix(mcp): cap list-tool pagesize and strip auto-generated outputSchema#149
frederik-raphael wants to merge 3 commits into
developfrom
fix/mcp-pagesize-cap

Conversation

@frederik-raphael

@frederik-raphael frederik-raphael commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

The problem

When an LLM calls an MCP list tool without a pagesize argument, that argument reaches the REST route as None. None does not select a sensible server default. It selects the internal ceiling, or no ceiling at all:

  • list_txs_for resolves to fetch_size = min(pagesize or BIG_PAGE_SIZE, BIG_PAGE_SIZE) with BIG_PAGE_SIZE = 5000 (db/asynchronous/cassandra.py:1597), so one call can return 5000 transaction rows.
  • list_neighbors does the same against SMALL_PAGE_SIZE = 1000.
  • list_tx_flows has no ceiling. Pagination needs both a page number and a pagesize, and the web service only assigns page 1 when a pagesize is present (web/service/txs_service.py:91), so an omitted pagesize skips the slice in db/asynchronous/services/txs_service.py:371 entirely. Every flow event of the transaction comes back and next_page is null, which leaves the model no way to page even if it wanted to. The trace fetch below it is unpaged too: execute_async_lowlevel builds SimpleStatement(q, fetch_size=None), and passing None explicitly (rather than leaving it unset) makes the driver skip its own default_fetch_size of 5000.

Models omit optional arguments constantly, so this is the common path, not the edge case.

What it costs

One list_txs_for call on an exchange hot wallet returned 2.7 MB, roughly 677k tokens. No current context window holds that, so a single tool call ends the session. Scaled from that same measurement, the new default page of 25 rows is about 1/200th of the payload and the caller walks the rest through next_page.

The database side differs per tool:

  • list_txs_for and list_neighbors pass pagesize down as the Cassandra fetch_size and return a real paging_state cursor. Capping cuts rows read per query, not only bytes returned.
  • list_tx_flows builds the full event list for the transaction and slices it in memory. Capping there saves the response and the model's context, not database work.

Addresses and transactions that trigger it

  • Exchange hot wallets, mixer outputs and service deposit addresses, where the transaction count runs into six figures. This is the measured case.
  • Account-model aggregator, bundler and exploit transactions, which fan out into hundreds of transfer events under one transaction hash. list_tx_flows exists as a separate paginated tool for exactly this shape, and it was the one tool still reaching upstream unpaginated.
  • Hub addresses with thousands of neighbors, where list_neighbors also runs one tag_summary lookup per row.

The fix

Two chokepoints, one default in mcp/pagesize.py (25 when the argument is missing):

  1. _params_from fills it in for the hand-written tools in tools/consolidated.py, which build their own query dict.
  2. PagesizeDefaultMiddleware fills it in for the auto-generated tools, which have no gslib code in the path. FastMCP's OpenAPITool passes the model's arguments straight to the RequestDirector.

There is deliberately no MCP-side ceiling. An explicit page size is the caller's decision and the route already bounds it (PagesizeQuery declares ge=1, le=5000), so a second clamp at the MCP layer would only stop a model from asking for what the API is willing to serve.

The middleware only touches tools that declare a pagesize query parameter, collected in make_component_fn while from_fastapi walks the routes. Scoping matters: list_neighbors reuses pagesize as a match target when tag_filter is set, with its own default of 50, so a blanket injection would have changed that behaviour silently. resolve_pagesize() also treats junk and non-positive values as omission, because middleware runs before FastMCP validates arguments against the tool schema.

Separately, the first commit drops the inherited OpenAPI response model from auto-generated tools. It cost up to 3.4k tokens per tool (graph_summary, list_block_txs) and no known client shows it to the model. tools/list went from about 18k to 9k tokens.

Paging still reaches the tail

Forcing a pagesize means every call now takes the paginated branch upstream, so the cursor has to work:

  • Against the real service, 60 flow events at 25 per page: 25, 25, 10, next_page null at the end, 60 distinct values with no gaps or duplicates.
  • Through an MCP client end to end: the injected default reaches the route, the returned cursor fetches a distinct second page, and an explicit pagesize=5000 passes through untouched.

Testing

  • tests/mcp/test_pagesize.py: default resolution, middleware scoping, and the page-1-to-page-3 round trip through an MCP client. The fixture sizes itself from DEFAULT_PAGESIZE, so it keeps exercising three pages if that default moves.
  • tests/db/test_txs_service.py: cursor walk against the real get_asset_flows_within_tx.
  • tests/mcp/test_route_filter.py, test_server_integration.py, test_consolidated.py: outputSchema stripping, collection of the paged-tool set, and the wiring assertion that list_tx_flows is capped while the consolidated tools are not.
  • Full local run: 765 tests in tests/mcp and tests/db, 328 in tests/web, ruff clean.
  • Not run: any call against a live backend. The measured 2.7 MB figure predates this branch.

Not covered

  • list_block_txs has no pagesize parameter at all and returns every transaction in the block with full IO. A Bitcoin block holds a few thousand. Fixing it means either adding pagination to the REST route or dropping the tool from the curated list.
  • lookup_tx_details returns unbounded inputs/outputs, upstream and downstream traces, and conversions.

An omitted pagesize reached the upstream as no limit: a single
list_txs_for on an exchange hot wallet returned ~2.7 MB (~677k LLM
tokens). _params_from now defaults pagesize to 25 and caps it at 100
for every list tool; callers page onward via next_page.

Auto-generated tools inherited their OpenAPI response model as an MCP
outputSchema (graph_summary ~3.4k tokens, list_block_txs ~3.2k) that no
known client shows the model; component_fn drops it. tools/list payload
shrinks from ~18k to ~9k tokens.
- Add PagesizeCapMiddleware so auto-generated tools (list_tx_flows) get the default/ceiling instead of reaching the route with no limit
- Share the capped() helper in mcp/pagesize.py with the consolidated tools, keeping one policy
- Verify paging onward (next_page cursors) still reaches the tail of fan-out txs
)

logger.info(f"Redis lock {key} acquired.")
logger.info(f"Redis lock {key} acquired by {token}.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think github mistook "token" as an access token. this token here is no secret

try:
lock.release()
client.delete(_heartbeat_key(key), _alert_key(key))
logger.info(f"Redis lock {key} released.")
logger.info(f"Redis lock {key} released.")
except Exception as e:
# Never let a release problem mask what the body did or raised.
logger.warning(f"Redis lock {key} could not be released: {e}")
f"needs this lock. Check that host, then force-release with: "
f"redis-cli DEL {key}"
)
logger.warning(msg)
send_msg_to_topic(_ALERT_TOPIC, msg)
except Exception as e:
# Reporting must never turn lock contention into a crash.
logger.warning(f"Could not report stale holder of lock {key}: {e}")
@frederik-raphael
frederik-raphael changed the base branch from master to develop August 27, 2026 11:48
@frederik-raphael frederik-raphael changed the title feat(spark): write real 64-bit tx counts; cap MCP list pagesize; fix wrapped readers fix(mcp): cap list-tool pagesize and strip auto-generated outputSchema Aug 27, 2026
An explicit page size is the caller's decision and the route already
bounds it: PagesizeQuery declares ge=1, le=5000, and the Cassandra layer
clamps again to BIG_PAGE_SIZE / SMALL_PAGE_SIZE. Clamping a second time
at 100 in the MCP layer only stopped a model from asking for what the
API is willing to serve.

Omission is the part that needed fixing, and it still is. `None` reaches
the route as the internal ceiling (5000 rows for list_txs_for) or, for
list_tx_flows, as no pagination at all: the slice needs both a page and a
page size, so every flow event comes back with next_page null.

So MAX_PAGESIZE is gone and only the default remains. capped() becomes
resolve_pagesize() and PagesizeCapMiddleware becomes
PagesizeDefaultMiddleware, since neither clamps anything now. The
list_neighbors docstring names both defaults explicitly: 25 unfiltered,
50 as a match target when tag_filter is set, which are different
quantities that happened to read as one number. The round-trip test
sizes its fixture from DEFAULT_PAGESIZE so it keeps exercising three
pages if that default ever moves again.
@Tommel71

Copy link
Copy Markdown
Member

One thought on the middleware: as far as I can tell the model never actually sees the 25. It's in the docstrings of the hand-written tools, but for list_tx_flows (the one the middleware is for) it's not documented anywhere, since the description comes from the curation YAML. So the only hint that truncation happened is next_page.

Have you looked at fastmcp's ToolTransform with ArgTransformConfig(default=25)? If I understand it right, that would put default: 25 directly into the inputSchema so the model sees it natively.

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.

3 participants