Skip to content

Commit ac3c0f2

Browse files
aprimakinaclaude
andauthored
feat(mcp): cap db_execute_query result size to protect agent context (#167)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e8c0afb commit ac3c0f2

9 files changed

Lines changed: 245 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ All configuration options can be set via `tiger config set <key> <value>`:
245245
- `color` - Enable/disable colored output (default: `true`)
246246
- `debug` - Enable/disable debug logging (default: `false`)
247247
- `docs_mcp` - Enable/disable docs MCP proxy (default: `true`)
248+
- `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`
248249
- `output` - Output format: `json`, `yaml`, or `table` (default: `table`)
249250
- `password_storage` - Password storage method: `keyring`, `pgpass`, or `none` (default: `keyring`)
250251
- `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 — `tiger db schema` and the `db_schema` MCP tool always open a read-only session regardless of this setting. 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: 4 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 {
@@ -155,6 +157,7 @@ password_storage: keyring
155157
"config_dir": tmpDir,
156158
"releases_url": "https://cli.tigerdata.com",
157159
"version_check": true,
160+
"mcp_max_rows": float64(config.DefaultMCPMaxRows),
158161
}
159162

160163
for key, expectedValue := range expectedValues {
@@ -212,6 +215,7 @@ password_storage: keyring
212215
"config_dir": tmpDir,
213216
"releases_url": "https://cli.tigerdata.com",
214217
"version_check": true,
218+
"mcp_max_rows": config.DefaultMCPMaxRows,
215219
}
216220

217221
for key, expectedValue := range expectedValues {

internal/tiger/config/config.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Config struct {
2626
DocsMCP bool `mapstructure:"docs_mcp"`
2727
DocsMCPURL string `mapstructure:"docs_mcp_url"`
2828
GatewayURL string `mapstructure:"gateway_url"`
29+
MCPMaxRows int `mapstructure:"mcp_max_rows"`
2930
Output string `mapstructure:"output"`
3031
PasswordStorage string `mapstructure:"password_storage"`
3132
ReadOnly bool `mapstructure:"read_only"`
@@ -45,6 +46,7 @@ type ConfigOutput struct {
4546
DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"`
4647
DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"`
4748
GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"`
49+
MCPMaxRows *int `mapstructure:"mcp_max_rows" json:"mcp_max_rows,omitempty"`
4850
Output *string `mapstructure:"output" json:"output,omitempty"`
4951
PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"`
5052
ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"`
@@ -63,6 +65,7 @@ const (
6365
DefaultDocsMCP = true
6466
DefaultDocsMCPURL = "https://mcp.tigerdata.com/docs?disabled_skills=ghost-database"
6567
DefaultGatewayURL = "https://console.cloud.tigerdata.com/api"
68+
DefaultMCPMaxRows = 100
6669
DefaultOutput = "table"
6770
DefaultPasswordStorage = "keyring"
6871
DefaultReadOnly = false
@@ -84,6 +87,7 @@ var defaultValues = map[string]any{
8487
"docs_mcp": DefaultDocsMCP,
8588
"docs_mcp_url": DefaultDocsMCPURL,
8689
"gateway_url": DefaultGatewayURL,
90+
"mcp_max_rows": DefaultMCPMaxRows,
8791
"output": DefaultOutput,
8892
"password_storage": DefaultPasswordStorage,
8993
"read_only": DefaultReadOnly,
@@ -282,6 +286,14 @@ func setBool(key, val string) (bool, error) {
282286
return b, nil
283287
}
284288

289+
func setInt(key, val string) (int, error) {
290+
n, err := strconv.Atoi(val)
291+
if err != nil {
292+
return 0, fmt.Errorf("invalid %s value: %s (must be an integer)", key, val)
293+
}
294+
return n, nil
295+
}
296+
285297
// UpdateField updates the field in the Config struct corresponding to the given key.
286298
// It accepts either a string (from user input) or a typed value (string/bool from defaults).
287299
// The function validates the value and updates both the struct field and viper state.
@@ -455,6 +467,26 @@ func (c *Config) UpdateField(key string, value any) (any, error) {
455467
return nil, fmt.Errorf("version_check must be string or bool, got %T", value)
456468
}
457469

470+
case "mcp_max_rows":
471+
var n int
472+
switch v := value.(type) {
473+
case int:
474+
n = v
475+
case string:
476+
parsed, err := setInt("mcp_max_rows", v)
477+
if err != nil {
478+
return nil, err
479+
}
480+
n = parsed
481+
default:
482+
return nil, fmt.Errorf("mcp_max_rows must be string or int, got %T", value)
483+
}
484+
if n < 1 {
485+
return nil, fmt.Errorf("mcp_max_rows must be at least 1, got %d", n)
486+
}
487+
c.MCPMaxRows = n
488+
validated = n
489+
458490
default:
459491
return nil, fmt.Errorf("unknown configuration key: %s", key)
460492
}

internal/tiger/config/config_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ func TestLoad_DefaultValues(t *testing.T) {
7373
if cfg.ReadOnly != DefaultReadOnly {
7474
t.Errorf("Expected ReadOnly %t, got %t", DefaultReadOnly, cfg.ReadOnly)
7575
}
76+
if cfg.MCPMaxRows != DefaultMCPMaxRows {
77+
t.Errorf("Expected MCPMaxRows %d, got %d", DefaultMCPMaxRows, cfg.MCPMaxRows)
78+
}
7679
if cfg.ConfigDir != tmpDir {
7780
t.Errorf("Expected ConfigDir %s, got %s", tmpDir, cfg.ConfigDir)
7881
}
@@ -418,6 +421,28 @@ func TestSet(t *testing.T) {
418421
value: "invalid",
419422
expectedError: true,
420423
},
424+
{
425+
key: "mcp_max_rows",
426+
value: "250",
427+
checkFunc: func() bool {
428+
return cfg.MCPMaxRows == 250
429+
},
430+
},
431+
{
432+
key: "mcp_max_rows",
433+
value: "0",
434+
expectedError: true,
435+
},
436+
{
437+
key: "mcp_max_rows",
438+
value: "-5",
439+
expectedError: true,
440+
},
441+
{
442+
key: "mcp_max_rows",
443+
value: "notanumber",
444+
expectedError: true,
445+
},
421446
{
422447
key: "unknown_key",
423448
value: "value",

internal/tiger/mcp/db_tools.go

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,42 @@ import (
1313
"go.uber.org/zap"
1414

1515
"github.com/timescale/tiger-cli/internal/tiger/common"
16+
"github.com/timescale/tiger-cli/internal/tiger/config"
1617
"github.com/timescale/tiger-cli/internal/tiger/logging"
1718
"github.com/timescale/tiger-cli/internal/tiger/util"
1819
)
1920

21+
const (
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.
24+
mcpMaxResponseBytes = 256 * 1024
25+
)
26+
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
32+
}
33+
return configured
34+
}
35+
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.
38+
func approxRowSize(values []any) int {
39+
if b, err := json.Marshal(values); err == nil {
40+
return len(b)
41+
}
42+
// Fallback for the rare value that isn't JSON-marshalable.
43+
return len(fmt.Sprint(values...))
44+
}
45+
46+
// truncationNotice builds the actionable guidance returned to the model when a
47+
// response is truncated.
48+
func truncationNotice(maxRows int) string {
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)
50+
}
51+
2052
// DBExecuteQueryInput represents input for db_execute_query
2153
type DBExecuteQueryInput struct {
2254
ServiceID string `json:"service_id"`
@@ -67,12 +99,15 @@ type ResultSet struct {
6799
Columns []DBExecuteQueryColumn `json:"columns,omitempty"`
68100
Rows *[][]any `json:"rows,omitempty"`
69101
RowsAffected int64 `json:"rows_affected"`
102+
Truncated bool `json:"truncated,omitempty"`
70103
}
71104

72105
// DBExecuteQueryOutput represents output for db_execute_query
73106
type DBExecuteQueryOutput struct {
74107
ResultSets []ResultSet `json:"result_sets"`
75108
ExecutionTime string `json:"execution_time"`
109+
Truncated bool `json:"truncated,omitempty"`
110+
Notice string `json:"notice,omitempty"`
76111
}
77112

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

99-
resultSetSchema.Properties["rows_affected"].Description = "Number of rows affected. For SELECT, this is the number of rows 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)."
100135
resultSetSchema.Properties["rows_affected"].Examples = []any{5, 42, 1000}
101136

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."
138+
102139
schema.Properties["execution_time"].Description = "Execution time as a human-readable duration string"
103140
schema.Properties["execution_time"].Examples = []any{"123ms", "1.5s", "45.2µs"}
104141

142+
schema.Properties["truncated"].Description = "True when any result set was truncated to limit the amount of data returned. See notice for guidance."
143+
144+
schema.Properties["notice"].Description = "Present only when results were truncated. Actionable guidance for getting the needed data via SQL (aggregate, filter, paginate) instead of re-running the query."
145+
105146
return schema
106147
}
107148

@@ -116,6 +157,8 @@ Connects to a PostgreSQL database service in Tiger Cloud and executes the provid
116157
117158
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.
118159
160+
Process data in the database, not in your context: aggregate, filter, sort/limit, and join in SQL rather than fetching raw rows.
161+
119162
WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`,
120163
InputSchema: DBExecuteQueryInput{}.Schema(),
121164
OutputSchema: DBExecuteQueryOutput{}.Schema(),
@@ -308,6 +351,10 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
308351
}
309352
defer conn.Close(context.Background())
310353

354+
// Bound how much data this call returns to the model's context.
355+
maxRows := resolveMaxRows(cfg.MCPMaxRows)
356+
remainingBytes := mcpMaxResponseBytes
357+
311358
// Execute query and measure time
312359
startTime := time.Now()
313360

@@ -324,6 +371,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
324371

325372
// Process all result sets, collecting them all
326373
resultSets := make([]ResultSet, 0)
374+
truncated := false
327375
for {
328376
rows, err := br.Query()
329377
if err != nil {
@@ -338,16 +386,24 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
338386
return nil, DBExecuteQueryOutput{}, err
339387
}
340388

341-
// Process this result set
342-
result, err := processResultSet(conn, rows)
389+
// Process this result set, capping rows and the shared byte budget.
390+
result, err := processResultSet(conn, rows, maxRows, &remainingBytes)
343391
if err != nil {
344392
return nil, DBExecuteQueryOutput{}, err
345393
}
346394

347395
// Collect this result set
348396
resultSets = append(resultSets, result)
397+
398+
if result.Truncated {
399+
// Stop reading further sets; br.Close() below discards them. The
400+
// query isn't cancelled, so all statements still run server-side.
401+
truncated = true
402+
break
403+
}
349404
}
350405

406+
// Close the batch, discarding any result sets we didn't read.
351407
if err := br.Close(); err != nil {
352408
return nil, DBExecuteQueryOutput{}, err
353409
}
@@ -357,12 +413,17 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ
357413
ResultSets: resultSets,
358414
ExecutionTime: time.Since(startTime).String(),
359415
}
416+
if truncated {
417+
output.Truncated = true
418+
output.Notice = truncationNotice(maxRows)
419+
}
360420

361421
return nil, output, nil
362422
}
363423

364-
// processResultSet reads all data from a pgx.Rows result set
365-
func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) {
424+
// processResultSet reads a result set, capping at maxRows and the shared byte
425+
// budget. ResultSet.Truncated reports whether rows were dropped.
426+
func processResultSet(conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) {
366427
defer rows.Close()
367428

368429
// Get column metadata from field descriptions
@@ -381,7 +442,7 @@ func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) {
381442
}
382443
}
383444

384-
// Collect all rows from this result set
445+
// Collect rows from this result set
385446
var resultRows [][]any
386447
if len(columns) > 0 {
387448
// If any columns were returned, initialize resultRows to an empty
@@ -392,25 +453,47 @@ func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) {
392453
// so we leave resultRows nil so it gets omitted from the JSON result.
393454
resultRows = make([][]any, 0)
394455
}
456+
457+
truncated := false
395458
for rows.Next() {
459+
// Row cap: another row exists but we already hold maxRows.
460+
if len(resultRows) >= maxRows {
461+
truncated = true
462+
break
463+
}
464+
396465
// Scan values into generic interface slice
397466
values, err := rows.Values()
398467
if err != nil {
399468
return ResultSet{}, err
400469
}
470+
471+
// Byte safety net for wide rows, but always keep at least one row so an
472+
// oversized first row doesn't yield an empty result.
473+
remaining := *remainingBytes - approxRowSize(values)
474+
if len(resultRows) > 0 && remaining < 0 {
475+
truncated = true
476+
break
477+
}
478+
*remainingBytes = remaining
479+
401480
resultRows = append(resultRows, values)
402481
}
403482

404-
// Check for errors during iteration
483+
// Drain so the command tag reports the true row count even when truncated.
484+
rows.Close()
485+
405486
if err := rows.Err(); err != nil {
406487
return ResultSet{}, err
407488
}
408489

409490
commandTag := rows.CommandTag()
491+
410492
return ResultSet{
411493
CommandTag: commandTag.String(),
412494
Columns: columns,
413495
Rows: util.PtrIfNonNil(resultRows),
414496
RowsAffected: commandTag.RowsAffected(),
497+
Truncated: truncated,
415498
}, nil
416499
}

0 commit comments

Comments
 (0)