Skip to content

Commit acebe3d

Browse files
aprimakinaclaude
andcommitted
refactor(mcp): address review on db_execute_query result limiting
Incorporate review feedback on the result-limiting change: - Drain instead of cancel on truncation, preserving the implicit transaction so writes and later statements still run, and reporting the true row count from the command tag. - Remove the hard 10000-row ceiling and the per-call max_rows parameter; mcp_max_rows config is now the single authoritative row cap. - Trim duplicative guidance text in the tool description and notice. - Show mcp_max_rows in `tiger config show` table output. - Fix the rows_affected schema description, which contradicted itself after rows_affected became the true total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e535147 commit acebe3d

7 files changed

Lines changed: 42 additions & 124 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ All configuration options can be set via `tiger config set <key> <value>`:
243243
- `color` - Enable/disable colored output (default: `true`)
244244
- `debug` - Enable/disable debug logging (default: `false`)
245245
- `docs_mcp` - Enable/disable docs MCP proxy (default: `true`)
246-
- `mcp_max_rows` - Default maximum number of rows the `db_execute_query` MCP tool returns per result set before truncating, to limit how much data lands in an AI agent's context. The tool's `max_rows` parameter overrides this per call, and both are hard-capped at 10000. Only applies to the MCP tool, not CLI commands. Default: `100`
246+
- `mcp_max_rows` - Maximum number of rows the `db_execute_query` MCP tool returns per result set before truncating, to limit how much data lands in an AI agent's context. Only applies to the MCP tool, not CLI commands. Default: `100`
247247
- `output` - Output format: `json`, `yaml`, or `table` (default: `table`)
248248
- `password_storage` - Password storage method: `keyring`, `pgpass`, or `none` (default: `keyring`)
249249
- `read_only` - When `true`, mutating operations are refused: the `tiger service create`/`fork`/`start`/`stop`/`resize`/`update-password`/`delete` CLI commands and their MCP equivalents return an error, and `tiger db connect`, `tiger db connection-string`, and the `db_execute_query` MCP tool open the database session in Tiger Cloud's immutable read-only mode (writes and DDL are rejected by the server). Read commands/tools are unaffected. Default: `false`.

internal/tiger/cmd/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ func outputTable(w io.Writer, cfg *config.ConfigOutput) error {
203203
if cfg.GatewayURL != nil {
204204
table.Append("gateway_url", *cfg.GatewayURL)
205205
}
206+
if cfg.MCPMaxRows != nil {
207+
table.Append("mcp_max_rows", fmt.Sprintf("%d", *cfg.MCPMaxRows))
208+
}
206209
if cfg.Color != nil {
207210
table.Append("color", fmt.Sprintf("%t", *cfg.Color))
208211
}

internal/tiger/cmd/config_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"os"
88
"slices"
9+
"strconv"
910
"strings"
1011
"testing"
1112

@@ -101,6 +102,7 @@ password_storage: pgpass
101102
"password_storage": "pgpass",
102103
"debug": "false",
103104
"config_dir": tmpDir,
105+
"mcp_max_rows": strconv.Itoa(config.DefaultMCPMaxRows),
104106
}
105107

106108
for key, expectedLine := range expectedLines {

internal/tiger/mcp/db_tools.go

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,22 @@ import (
1919
)
2020

2121
const (
22-
// mcpMaxRowsCeiling is the hard upper bound on rows returned per result set,
23-
// regardless of the configured or per-call max_rows.
24-
mcpMaxRowsCeiling = 10000
25-
26-
// mcpMaxResponseBytes caps the total serialized row data across a response,
27-
// guarding the model's context against a few very wide rows that the row cap
28-
// alone would miss. Not user-configurable.
22+
// mcpMaxResponseBytes caps total serialized row data per response, catching a
23+
// few very wide rows the row cap alone would miss. Not user-configurable.
2924
mcpMaxResponseBytes = 256 * 1024
3025
)
3126

32-
// resolveMaxRows returns the effective per-result-set row cap: the per-call
33-
// value if > 0, else the configured value, else the default, clamped to
34-
// mcpMaxRowsCeiling. It also sanitizes non-positive config-file/env values
35-
// (which bypass `tiger config set` validation) to the default.
36-
func resolveMaxRows(configured, requested int) int {
37-
n := configured
38-
if requested > 0 {
39-
n = requested
40-
}
41-
if n <= 0 {
42-
n = config.DefaultMCPMaxRows
27+
// resolveMaxRows returns the row cap from mcp_max_rows, falling back to the
28+
// default for non-positive config-file/env values (which skip set validation).
29+
func resolveMaxRows(configured int) int {
30+
if configured <= 0 {
31+
return config.DefaultMCPMaxRows
4332
}
44-
if n > mcpMaxRowsCeiling {
45-
n = mcpMaxRowsCeiling
46-
}
47-
return n
33+
return configured
4834
}
4935

50-
// approxRowSize estimates the serialized size, in bytes, of a single row's
51-
// values. It is used to enforce the overall response byte budget. The estimate
52-
// mirrors how the row is ultimately serialized to JSON for the client.
36+
// approxRowSize estimates a row's serialized size in bytes for the byte budget,
37+
// mirroring how it is ultimately marshaled to JSON for the client.
5338
func approxRowSize(values []any) int {
5439
if b, err := json.Marshal(values); err == nil {
5540
return len(b)
@@ -61,7 +46,7 @@ func approxRowSize(values []any) int {
6146
// truncationNotice builds the actionable guidance returned to the model when a
6247
// response is truncated.
6348
func truncationNotice(maxRows int) string {
64-
return fmt.Sprintf("Results were truncated to limit the amount of data returned (max_rows=%d per result set, plus an overall response size cap). More rows exist. Do the work in the database instead of re-running this query: aggregate (GROUP BY, COUNT, SUM, AVG), filter (WHERE), or paginate (LIMIT/OFFSET). To pull more raw rows in a single call, set max_rows (up to %d).", maxRows, mcpMaxRowsCeiling)
49+
return fmt.Sprintf("Results were truncated to limit the amount of data returned (the configured mcp_max_rows=%d per result set, plus an overall response size cap). More rows exist. Do the work in the database instead of re-running this query: aggregate (GROUP BY, COUNT, SUM, AVG), filter (WHERE), or paginate (LIMIT/OFFSET).", maxRows)
6550
}
6651

6752
// DBExecuteQueryInput represents input for db_execute_query
@@ -72,7 +57,6 @@ type DBExecuteQueryInput struct {
7257
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
7358
Role string `json:"role,omitempty"`
7459
Pooled bool `json:"pooled,omitempty"`
75-
MaxRows int `json:"max_rows,omitempty"`
7660
}
7761

7862
func (DBExecuteQueryInput) Schema() *jsonschema.Schema {
@@ -100,12 +84,6 @@ func (DBExecuteQueryInput) Schema() *jsonschema.Schema {
10084
schema.Properties["pooled"].Default = util.Must(json.Marshal(false))
10185
schema.Properties["pooled"].Examples = []any{false, true}
10286

103-
// No schema Default: it would be injected by the SDK and shadow the
104-
// configured mcp_max_rows fallback when max_rows is omitted.
105-
schema.Properties["max_rows"].Description = fmt.Sprintf("Maximum number of rows to return per result set. When the query produces more, the result set is truncated (the response indicates this) and the in-flight query is aborted to avoid streaming and buffering data the model won't use. Defaults to the configured mcp_max_rows (%d). Hard-capped at %d. Prefer aggregating or filtering in SQL over raising this.", config.DefaultMCPMaxRows, mcpMaxRowsCeiling)
106-
schema.Properties["max_rows"].Minimum = util.Ptr(1.0)
107-
schema.Properties["max_rows"].Examples = []any{100, 500}
108-
10987
return schema
11088
}
11189

@@ -153,10 +131,10 @@ func (DBExecuteQueryOutput) Schema() *jsonschema.Schema {
153131
resultSetSchema.Properties["rows"].Description = "Result rows as arrays of values. Omitted for commands that don't return rows (INSERT, UPDATE, DELETE, etc.)"
154132
resultSetSchema.Properties["rows"].Examples = []any{[][]any{{1, "alice", "2024-01-01"}, {2, "bob", "2024-01-02"}}}
155133

156-
resultSetSchema.Properties["rows_affected"].Description = "Number of rows affected. For SELECT, this is the number of rows returned (which equals the number of rows in this response; when truncated is true, more rows existed but were not returned). For INSERT/UPDATE/DELETE, this is the number of rows modified. Returns 0 for statements that don't return or modify rows (e.g. CREATE TABLE)."
134+
resultSetSchema.Properties["rows_affected"].Description = "Number of rows affected. For SELECT, this is the total number of rows the query produced; when truncated is true this exceeds the number of rows actually returned in this response. For INSERT/UPDATE/DELETE, this is the number of rows modified. Returns 0 for statements that don't return or modify rows (e.g. CREATE TABLE)."
157135
resultSetSchema.Properties["rows_affected"].Examples = []any{5, 42, 1000}
158136

159-
resultSetSchema.Properties["truncated"].Description = "True when this result set was capped (by max_rows or the overall response size limit) and additional rows exist that were not returned. Refine the query in SQL to get the data you need."
137+
resultSetSchema.Properties["truncated"].Description = "True when this result set was capped (by the configured mcp_max_rows row limit or the overall response size limit) and additional rows exist that were not returned. Refine the query in SQL to get the data you need."
160138

161139
schema.Properties["execution_time"].Description = "Execution time as a human-readable duration string"
162140
schema.Properties["execution_time"].Examples = []any{"123ms", "1.5s", "45.2µs"}
@@ -179,9 +157,7 @@ Connects to a PostgreSQL database service in Tiger Cloud and executes the provid
179157
180158
Multi-statement queries (semicolon-separated) are supported when no parameters are provided. All result sets are returned. By default, statements execute in an implicit transaction that automatically commits on success or rolls back on error. Explicit transactions (opened with BEGIN) must be explicitly committed with COMMIT, or they roll back when the connection closes.
181159
182-
DO THE WORK IN THE DATABASE. PostgreSQL is far more efficient at processing data than fetching raw rows and computing in your context. Push computation into SQL: aggregate with GROUP BY / COUNT / SUM / AVG / MIN / MAX, filter with WHERE, sort and take the top N with ORDER BY ... LIMIT, and join/transform server-side. Avoid SELECT * on large tables; project only the columns you need.
183-
184-
RESULTS ARE CAPPED. Each result set returns at most max_rows rows (default 100, configurable via mcp_max_rows), and the total response size is bounded. If a result set is truncated, the response sets "truncated": true and includes a "notice" — refine the query in SQL (aggregate, filter, or paginate with LIMIT/OFFSET) rather than re-running it to pull everything. Only raise max_rows when you genuinely need more raw rows in a single call.
160+
Process data in the database, not in your context: aggregate, filter, sort/limit, and join in SQL rather than fetching raw rows. Results are capped per result set (default 100 rows, configurable via mcp_max_rows) and by total size; a truncated response sets "truncated": true with a "notice" on how to refine the query.
185161
186162
WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`,
187163
InputSchema: DBExecuteQueryInput{}.Schema(),
@@ -281,7 +257,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
281257
defer conn.Close(context.Background())
282258

283259
// Bound how much data this call returns to the model's context.
284-
maxRows := resolveMaxRows(cfg.MCPMaxRows, input.MaxRows)
260+
maxRows := resolveMaxRows(cfg.MCPMaxRows)
285261
remainingBytes := mcpMaxResponseBytes
286262

287263
// Execute query and measure time
@@ -316,7 +292,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
316292
}
317293

318294
// Process this result set, capping rows and the shared byte budget.
319-
result, err := processResultSet(cancel, conn, rows, maxRows, &remainingBytes)
295+
result, err := processResultSet(conn, rows, maxRows, &remainingBytes)
320296
if err != nil {
321297
return nil, DBExecuteQueryOutput{}, err
322298
}
@@ -325,15 +301,15 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
325301
resultSets = append(resultSets, result)
326302

327303
if result.Truncated {
328-
// processResultSet already aborted the query (via cancel); stop
329-
// reading the batch.
304+
// Stop reading further sets; br.Close() below discards them. The
305+
// query isn't cancelled, so all statements still run server-side.
330306
truncated = true
331307
break
332308
}
333309
}
334310

335-
// After an abort, br.Close() returns the expected cancellation error.
336-
if err := br.Close(); err != nil && !truncated {
311+
// Close the batch, discarding any result sets we didn't read.
312+
if err := br.Close(); err != nil {
337313
return nil, DBExecuteQueryOutput{}, err
338314
}
339315

@@ -350,11 +326,9 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
350326
return nil, output, nil
351327
}
352328

353-
// processResultSet reads a pgx.Rows result set, capping at maxRows and at the
354-
// shared byte budget (remainingBytes). The returned ResultSet.Truncated reports
355-
// whether rows were dropped; on truncation it cancels to abort the query rather
356-
// than let pgx drain the rest.
357-
func processResultSet(cancel context.CancelFunc, conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) {
329+
// processResultSet reads a result set, capping at maxRows and the shared byte
330+
// budget. ResultSet.Truncated reports whether rows were dropped.
331+
func processResultSet(conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) {
358332
defer rows.Close()
359333

360334
// Get column metadata from field descriptions
@@ -401,36 +375,32 @@ func processResultSet(cancel context.CancelFunc, conn *pgx.Conn, rows pgx.Rows,
401375

402376
// Byte safety net for wide rows, but always keep at least one row so an
403377
// oversized first row doesn't yield an empty result.
404-
size := approxRowSize(values)
405-
if len(resultRows) > 0 && *remainingBytes-size < 0 {
378+
remaining := *remainingBytes - approxRowSize(values)
379+
if len(resultRows) > 0 && remaining < 0 {
406380
truncated = true
407381
break
408382
}
409-
*remainingBytes -= size
383+
*remainingBytes = remaining
410384

411385
resultRows = append(resultRows, values)
412386
}
413387

414388
if truncated {
415-
// Cancel before the deferred rows.Close() so pgx aborts the query
416-
// instead of draining the remaining rows.
417-
cancel()
389+
// Drain so the command tag (and true row count) is available.
390+
rows.Close()
418391
} else if err := rows.Err(); err != nil {
419392
return ResultSet{}, err
420393
}
421394

395+
// After a full drain the command tag reports the true row count, even when
396+
// we returned fewer.
422397
commandTag := rows.CommandTag()
423-
rowsAffected := commandTag.RowsAffected()
424-
if truncated {
425-
// The command tag is unreliable after an abort; report rows returned.
426-
rowsAffected = int64(len(resultRows))
427-
}
428398

429399
return ResultSet{
430400
CommandTag: commandTag.String(),
431401
Columns: columns,
432402
Rows: util.PtrIfNonNil(resultRows),
433-
RowsAffected: rowsAffected,
403+
RowsAffected: commandTag.RowsAffected(),
434404
Truncated: truncated,
435405
}, nil
436406
}

internal/tiger/mcp/db_tools_test.go

Lines changed: 3 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -11,72 +11,32 @@ func TestResolveMaxRows(t *testing.T) {
1111
tests := []struct {
1212
name string
1313
configured int
14-
requested int
1514
want int
1615
}{
1716
{
18-
name: "both unset falls back to default",
19-
configured: 0,
20-
requested: 0,
21-
want: config.DefaultMCPMaxRows,
22-
},
23-
{
24-
name: "configured used when no per-call value",
17+
name: "configured value is used",
2518
configured: 250,
26-
requested: 0,
2719
want: 250,
2820
},
29-
{
30-
name: "per-call overrides configured",
31-
configured: 250,
32-
requested: 10,
33-
want: 10,
34-
},
35-
{
36-
name: "per-call overrides default when configured unset",
37-
configured: 0,
38-
requested: 42,
39-
want: 42,
40-
},
41-
{
42-
name: "per-call clamped to ceiling",
43-
configured: 100,
44-
requested: mcpMaxRowsCeiling + 5000,
45-
want: mcpMaxRowsCeiling,
46-
},
47-
{
48-
name: "configured clamped to ceiling",
49-
configured: mcpMaxRowsCeiling + 1,
50-
requested: 0,
51-
want: mcpMaxRowsCeiling,
52-
},
5321
{
5422
// A config-file or TIGER_MCP_MAX_ROWS value bypasses `tiger config
5523
// set` validation, so a zero (or negative) configured value can
5624
// reach here and must be sanitized to the default.
5725
name: "zero configured (env/file bypass) falls back to default",
5826
configured: 0,
59-
requested: 0,
6027
want: config.DefaultMCPMaxRows,
6128
},
6229
{
6330
name: "negative configured falls back to default",
6431
configured: -1,
65-
requested: 0,
6632
want: config.DefaultMCPMaxRows,
6733
},
68-
{
69-
name: "negative per-call value is ignored in favor of configured",
70-
configured: 250,
71-
requested: -5,
72-
want: 250,
73-
},
7434
}
7535

7636
for _, tt := range tests {
7737
t.Run(tt.name, func(t *testing.T) {
78-
if got := resolveMaxRows(tt.configured, tt.requested); got != tt.want {
79-
t.Errorf("resolveMaxRows(%d, %d) = %d, want %d", tt.configured, tt.requested, got, tt.want)
38+
if got := resolveMaxRows(tt.configured); got != tt.want {
39+
t.Errorf("resolveMaxRows(%d) = %d, want %d", tt.configured, got, tt.want)
8040
}
8141
})
8242
}
@@ -108,22 +68,6 @@ func TestTruncationNotice(t *testing.T) {
10868
}
10969
}
11070

111-
func TestDBExecuteQueryInputSchemaHasMaxRows(t *testing.T) {
112-
schema := DBExecuteQueryInput{}.Schema()
113-
prop, ok := schema.Properties["max_rows"]
114-
if !ok {
115-
t.Fatal("expected max_rows property in input schema")
116-
}
117-
if prop.Description == "" {
118-
t.Error("expected max_rows to have a description")
119-
}
120-
// No default: omitting max_rows must fall back to the configured value,
121-
// not a schema-injected default.
122-
if prop.Default != nil {
123-
t.Errorf("expected max_rows to have no schema default, got %s", string(prop.Default))
124-
}
125-
}
126-
12771
func TestDBExecuteQueryOutputSchemaHasTruncationFields(t *testing.T) {
12872
schema := DBExecuteQueryOutput{}.Schema()
12973
for _, name := range []string{"truncated", "notice"} {

specs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ All configuration options can be set via `tiger config set <key> <value>`:
4646
- `color` - Enable/disable colored output (default: true)
4747
- `debug` - Enable/disable debug logging (default: false)
4848
- `docs_mcp` - Enable/disable docs MCP proxy (default: true)
49-
- `mcp_max_rows` - Default maximum rows the `db_execute_query` MCP tool returns per result set before truncating, to limit data placed in an AI agent's context. Overridable per call via the tool's `max_rows` parameter; both are hard-capped at 10000. MCP-only (does not affect CLI commands). (default: 100). See `specs/spec_mcp.md` for details.
49+
- `mcp_max_rows` - Maximum rows the `db_execute_query` MCP tool returns per result set before truncating, to limit data placed in an AI agent's context. MCP-only (does not affect CLI commands). (default: 100). See `specs/spec_mcp.md` for details.
5050
- `output` - Output format: json, yaml, or table (default: table)
5151
- `password_storage` - Password storage method: keyring, pgpass, or none (default: keyring)
5252
- `read_only` - When `true`, mutating operations are refused: `tiger service create`/`fork`/`start`/`stop`/`resize`/`update-password`/`delete` and their MCP equivalents return an error, and `tiger db connect`/`connection-string`/`db_execute_query` open against an immutable read-only database connection regardless of `--read-only` (default: false). See `specs/spec_mcp.md` for details.

0 commit comments

Comments
 (0)