Conversation
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
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.
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, default1000,0restores the previous unbounded behavior.Same 3M row table from the issue, same query, same default 30s timeout:
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_streamand the stream is closed once the bound is reached. The query text is never touched. Appending aLIMITwould need to understand the statement, and would misfire on queries that already carry their ownLIMITorFORMATclause, and on statements that cannot be wrapped in a subquery at all. Several MCP servers in other ecosystems gateLIMITinjection on a substring test like"LIMIT" not in query.upper(), which a column namedlimit_amountdefeats. 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_rowsrows is reported as complete, becausereturned == max_result_rowson 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.
truncatedandmax_result_rowsappear 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:result_overflow_mode='throw'is exact but turns a large result intoCode: 396instead 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
1000rather than something larger after measuring: 10,000 rows of that table is still ~200k tokens, which is a whole context window for many clients.1000is roughly 20k tokens there. This is question 1 in the issue and entirely yours to call, including making it opt-in with0if 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,0disabling the bound, empty results, and column aliasing.FakeQueryClientintests/test_context_config_override.pygains aquery_row_block_streammethod so the double matches the APIexecute_querynow 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 assertssystem.tablesappears in the first 100 tables ofsystem, which stopped holding on ClickHouse newer than the 24.10 used in CI.Not included
Stringcolumns is a very different payload from 1,000 rows ofUInt8. This is question 2 in the issue; happy to add it here or leave it for a follow-up.list_tables(question 4).