fix(mcp): cap list-tool pagesize and strip auto-generated outputSchema - #149
Open
frederik-raphael wants to merge 3 commits into
Open
fix(mcp): cap list-tool pagesize and strip auto-generated outputSchema#149frederik-raphael wants to merge 3 commits into
frederik-raphael wants to merge 3 commits into
Conversation
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}.") |
Member
There was a problem hiding this comment.
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}") |
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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
When an LLM calls an MCP list tool without a
pagesizeargument, that argument reaches the REST route asNone.Nonedoes not select a sensible server default. It selects the internal ceiling, or no ceiling at all:list_txs_forresolves tofetch_size = min(pagesize or BIG_PAGE_SIZE, BIG_PAGE_SIZE)withBIG_PAGE_SIZE = 5000(db/asynchronous/cassandra.py:1597), so one call can return 5000 transaction rows.list_neighborsdoes the same againstSMALL_PAGE_SIZE = 1000.list_tx_flowshas 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 indb/asynchronous/services/txs_service.py:371entirely. Every flow event of the transaction comes back andnext_pageis null, which leaves the model no way to page even if it wanted to. The trace fetch below it is unpaged too:execute_async_lowlevelbuildsSimpleStatement(q, fetch_size=None), and passingNoneexplicitly (rather than leaving it unset) makes the driver skip its owndefault_fetch_sizeof 5000.Models omit optional arguments constantly, so this is the common path, not the edge case.
What it costs
One
list_txs_forcall 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 throughnext_page.The database side differs per tool:
list_txs_forandlist_neighborspass pagesize down as the Cassandrafetch_sizeand return a realpaging_statecursor. Capping cuts rows read per query, not only bytes returned.list_tx_flowsbuilds 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
list_tx_flowsexists as a separate paginated tool for exactly this shape, and it was the one tool still reaching upstream unpaginated.list_neighborsalso runs one tag_summary lookup per row.The fix
Two chokepoints, one default in
mcp/pagesize.py(25 when the argument is missing):_params_fromfills it in for the hand-written tools intools/consolidated.py, which build their own query dict.PagesizeDefaultMiddlewarefills it in for the auto-generated tools, which have no gslib code in the path. FastMCP'sOpenAPIToolpasses the model's arguments straight to theRequestDirector.There is deliberately no MCP-side ceiling. An explicit page size is the caller's decision and the route already bounds it (
PagesizeQuerydeclaresge=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
pagesizequery parameter, collected inmake_component_fnwhilefrom_fastapiwalks the routes. Scoping matters:list_neighborsreusespagesizeas a match target whentag_filteris 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/listwent 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:
next_pagenull at the end, 60 distinct values with no gaps or duplicates.pagesize=5000passes 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 fromDEFAULT_PAGESIZE, so it keeps exercising three pages if that default moves.tests/db/test_txs_service.py: cursor walk against the realget_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 thatlist_tx_flowsis capped while the consolidated tools are not.tests/mcpandtests/db, 328 intests/web, ruff clean.Not covered
list_block_txshas 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_detailsreturns unboundedinputs/outputs, upstream and downstream traces, and conversions.