Skip to content

feat: bound run_query result size - #224

Draft
mosya415 wants to merge 1 commit into
ClickHouse:mainfrom
mosya415:feat/bound-result-size
Draft

mosya415 wants to merge 1 commit into
ClickHouse:mainfrom
mosya415:feat/bound-result-size

Conversation

@mosya415

Copy link
Copy Markdown
Contributor

Draft implementation for #223, opened to make that discussion concrete rather than to pre-empt it. The open questions in the issue are still open, and I will happily rework the shape.

What it does

Adds CLICKHOUSE_MCP_MAX_RESULT_ROWS, default 1000, 0 restores the previous unbounded behavior.

Same 3M row table from the issue, same query, same default 30s timeout:

                  before          after
elapsed           5.2s            0.12s
returned rows     3,000,000       1,000
payload           231.6 MiB       0.08 MiB
rough tokens      60,700,916      ~20,000

The query is also aborted early, so ClickHouse stops sending rather than serializing 3M rows that get discarded.

Implementation notes

Streaming instead of LIMIT injection. Rows are read with query_row_block_stream and the stream is closed once the bound is reached. The query text is never touched. Appending a LIMIT would need to understand the statement, and would misfire on queries that already carry their own LIMIT or FORMAT clause, and on statements that cannot be wrapped in a subquery at all. Several MCP servers in other ecosystems gate LIMIT injection on a substring test like "LIMIT" not in query.upper(), which a column named limit_amount defeats. Not rewriting SQL avoids the whole class.

Truncation is proven, not inferred. The read stops on the row after the bound. A result holding exactly max_result_rows rows is reported as complete, because returned == max_result_rows on its own cannot distinguish a full result of that size from a truncated one. There is a regression test for exactly this case.

Backward compatible response shape. Complete results keep exactly the shape they have today. truncated and max_result_rows appear only when truncation actually happened, so complete results pay nothing for the metadata.

Why not ClickHouse's own max_result_rows. It is enforced per block, so it is not a cap a user can reason about. Measured on the same table:

asked max_result_rows=10      -> got  16,384 rows
asked max_result_rows=100     -> got  16,384 rows
asked max_result_rows=1000    -> got  16,384 rows
asked max_result_rows=50000   -> got  65,536 rows

result_overflow_mode='throw' is exact but turns a large result into Code: 396 instead of a usable prefix. It could still be worth layering underneath as a coarse server-side backstop; I left it out to keep this change small.

Tool description. run_query's description now states that results are capped and that a truncated response holds only the first rows, so the model aggregates in SQL instead of treating a prefix as the whole table. Truncating silently is the failure mode worth avoiding: a partial answer presented as a complete one is worse than either a complete answer or an explicit error.

On the default

I picked 1000 rather than something larger after measuring: 10,000 rows of that table is still ~200k tokens, which is a whole context window for many clients. 1000 is roughly 20k tokens there. This is question 1 in the issue and entirely yours to call, including making it opt-in with 0 if you would rather not change observable behavior in a patch release.

Tests

New tests/test_result_limits.py: 9 parametrized unit tests for the streaming helper covering the exact-boundary case, block boundaries, empty results, early stream closure, and settings passthrough; plus 7 integration tests against a real server covering truncation, flag absence on complete results, 0 disabling the bound, empty results, and column aliasing.

FakeQueryClient in tests/test_context_config_override.py gains a query_row_block_stream method so the double matches the API execute_query now uses. Its assertions are unchanged.

Full suite locally: 307 passed. The one failure, test_system_database_access, is pre-existing and unrelated; it fails on an unmodified checkout too, because it asserts system.tables appears in the first 100 tables of system, which stopped holding on ClickHouse newer than the 24.10 used in CI.

Not included

  • A byte bound. 1,000 rows of wide String columns is a very different payload from 1,000 rows of UInt8. This is question 2 in the issue; happy to add it here or leave it for a follow-up.
  • Any change to list_tables (question 4).

The query timeout bounds how long a query runs, not how much it returns.
A fast query over a large table finishes well inside the timeout and then
returns everything: a 3M row table produced a 231 MiB payload in 5.2s
under the 30s default timeout.

Add CLICKHOUSE_MCP_MAX_RESULT_ROWS (default 1000, 0 restores the previous
unbounded behavior). Rows are streamed and the stream is closed once the
bound is reached, so the query text is never rewritten. Appending a LIMIT
would need to understand the statement and would misfire on queries that
already carry their own LIMIT or FORMAT clause, and on statements that
cannot be wrapped in a subquery.

Truncation is proven rather than inferred: the read stops on the row after
the bound, so a result holding exactly max_result_rows rows is reported as
complete. Comparing the returned count against the bound alone cannot tell
those two cases apart.

Truncated responses gain "truncated": true and "max_result_rows". Complete
responses keep exactly the shape they had before, so the common case is
unchanged and pays no extra tokens for metadata.

The FakeQueryClient double in the context-override tests grows a
query_row_block_stream method to match the API execute_query now uses; its
assertions are unchanged.

This branch has not been deployed

No deployments
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.

1 participant