Skip to content

query: enforce result caps as server config instead of rewriting SQL#125

Open
BorisTyshkevich wants to merge 1 commit into
mainfrom
query/result-caps-124
Open

query: enforce result caps as server config instead of rewriting SQL#125
BorisTyshkevich wants to merge 1 commit into
mainfrom
query/result-caps-124

Conversation

@BorisTyshkevich
Copy link
Copy Markdown
Collaborator

Closes #124.

Summary

  • Drop the limit argument from execute_query and the OpenAPI ?limit= query parameter — the rewrite-the-SQL approach broke DESCRIBE/SHOW/EXISTS and solved the wrong problem.
  • Add two operator-controlled config keys: clickhouse.max_result_rows (default 500) and clickhouse.max_result_bytes (default 50000). 0 = use default, negative = disable (defer to ClickHouse user profile).
  • Keep clickhouse.limit as a silent alias for max_result_rows; log a one-time deprecation warning at startup when it's set.
  • Apply caps uniformly to execute_query (MCP + OpenAPI) and read-mode dynamic tools (MCP + OpenAPI). write_query and write-mode dynamic tools are unaffected. Discovery clients (getDiscoveryClient) keep calling plain ExecuteQuery, so internal introspection queries stay uncapped.
  • Two-layer enforcement: ClickHouse session settings (max_result_rows = configured+1, max_result_bytes, result_overflow_mode='break') pushed per-query via clickhouse.Context(WithSettings) so the engine stops early; plus a hard cap inside executeSelect's rows.Next() loop for exact row counts, MCP-side OOM protection, and resilience to readonly=1 CH user profiles.
  • Truncation is surfaced explicitly so the model knows its view is partial:
    • QueryResult.Truncated JSON field with reason / limit / returned_rows / returned_bytes_approx.
    • MCP tool responses append a second TextContent block recommending narrowing the query (tighter WHERE, aggregation, key-range pagination, narrower SELECT list — not "smaller LIMIT", since a model that wanted 5000 rows and got 500 wants more).
    • OpenAPI responses set X-MCP-Truncated: max_result_rows (or max_result_bytes).
  • Updated docs/tools.md with the new keys, semantics, and signaling contract.

Test plan

  • go test -count=1 ./... passes against embedded ClickHouse — all packages green.
  • Regression coverage for issue execute_query: stop rewriting SQL with LIMIT; enforce row/byte caps for SELECTs as server-config defaults, with a post-receive belt-and-suspenders cap #124: DESCRIBE / DESC / SHOW CREATE TABLE / EXISTS TABLE / EXPLAIN SELECT round-trip with caps configured (previously rejected as syntax errors).
  • Row cap fires exactly: configure max_result_rows: 1, unbounded SELECT returns 1 row, Truncated.Reason = "max_result_rows", second content block present.
  • Byte cap fires first on wide rows: max_result_bytes: 64 against SELECT number, repeat('x', 100) ... yields Truncated.Reason = "max_result_bytes", notice mentions SELECT *.
  • Caps disabled (-1): unbounded SELECT ... LIMIT 1000 returns 1000 rows, no truncation, no notice block.
  • write_query (INSERT/CREATE) unaffected — no settings injected, no truncation flag.
  • OpenAPI: X-MCP-Truncated header set + body truncated field populated.
  • Post-merge: build image with scripts/build-mcp-image.sh, deploy to otel, run test-mcp-connector subagent (claude.ai only, cleanup always). Pass criteria: whoami returns email; execute_query "SELECT number FROM system.numbers LIMIT 10000" returns the default 500 rows + truncation notice; execute_query "DESCRIBE system.tables" succeeds (regression).

🤖 Generated with Claude Code

…124)

execute_query used to append " LIMIT N" when callers passed the limit
argument. That broke DESCRIBE/SHOW/EXISTS (rewrites them to invalid SQL,
since IsSelectQuery returns true for those) and solved the wrong
problem — the real concern is DoS and model context exhaustion, which
is an operator concern, not a per-call argument.

Drop the limit argument. Add two server-config keys:

  clickhouse.max_result_rows   (default 500, <0 disables)
  clickhouse.max_result_bytes  (default 50000, <0 disables)

The deprecated clickhouse.limit is kept as a silent alias for
max_result_rows with a one-time startup warning.

Caps apply to SELECT-like queries only (execute_query, dynamic read
tools, OpenAPI variants) — write_query and dynamic write tools are
unaffected. They are enforced in two layers: ClickHouse session
settings pushed per-query (max_result_rows = configured+1,
max_result_bytes, result_overflow_mode='break') so the engine stops
early, plus a hard cap in executeSelect's row loop that guarantees an
exact row count, bounds MCP-side memory, and works even when the CH
user profile rejects settings changes. Discovery clients keep calling
ExecuteQuery unchanged so they are not capped.

When a cap fires, QueryResult.Truncated carries reason/limit/returned
counts. MCP tool responses append a second TextContent block with
human-readable guidance — "narrow your WHERE / aggregate / avoid
SELECT *", not "use a smaller LIMIT", since a model that asked for
5000 rows and got 500 wants more, not less. OpenAPI responses set
X-MCP-Truncated alongside the same body field.

Closes #124

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

execute_query: stop rewriting SQL with LIMIT; enforce row/byte caps for SELECTs as server-config defaults, with a post-receive belt-and-suspenders cap

1 participant