From b0e7072523db13eb88a1655be03c98b02f39bee4 Mon Sep 17 00:00:00 2001 From: Alexandra Primakina Date: Fri, 19 Jun 2026 14:10:43 +0200 Subject: [PATCH 1/3] feat(mcp): cap db_execute_query result size to protect agent context LLMs using db_execute_query tend to SELECT raw rows and process locally, blowing up the context window and running slowly. Cap the data the tool returns and steer agents toward doing the work in SQL. - Row cap (per result set) via new mcp_max_rows config (default 100), overridable per call by a max_rows parameter, hard-capped at 10000. - 256 KiB byte safety net across the response for very wide rows. - On truncation, abort the in-flight query (context cancel, no pgx drain), flag `truncated`, and return an actionable `notice`. Always keep at least one row so an oversized first row never yields an empty result. - Server instructions + tool description push aggregation/filtering/ pagination into SQL. MCP-only; CLI query paths are unchanged. Co-Authored-By: Claude Opus 4.8 --- README.md | 1 + internal/tiger/cmd/config_test.go | 2 + internal/tiger/config/config.go | 32 ++++++ internal/tiger/config/config_test.go | 25 +++++ internal/tiger/mcp/db_tools.go | 135 +++++++++++++++++++++++-- internal/tiger/mcp/db_tools_test.go | 142 +++++++++++++++++++++++++++ internal/tiger/mcp/server.go | 4 +- specs/spec.md | 1 + specs/spec_mcp.md | 4 + 9 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 internal/tiger/mcp/db_tools_test.go diff --git a/README.md b/README.md index 4b681260..a816dfbe 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ All configuration options can be set via `tiger config set `: - `color` - Enable/disable colored output (default: `true`) - `debug` - Enable/disable debug logging (default: `false`) - `docs_mcp` - Enable/disable docs MCP proxy (default: `true`) +- `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` - `output` - Output format: `json`, `yaml`, or `table` (default: `table`) - `password_storage` - Password storage method: `keyring`, `pgpass`, or `none` (default: `keyring`) - `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`. diff --git a/internal/tiger/cmd/config_test.go b/internal/tiger/cmd/config_test.go index 62ed5179..6ae41038 100644 --- a/internal/tiger/cmd/config_test.go +++ b/internal/tiger/cmd/config_test.go @@ -155,6 +155,7 @@ password_storage: keyring "config_dir": tmpDir, "releases_url": "https://cli.tigerdata.com", "version_check": true, + "mcp_max_rows": float64(config.DefaultMCPMaxRows), } for key, expectedValue := range expectedValues { @@ -212,6 +213,7 @@ password_storage: keyring "config_dir": tmpDir, "releases_url": "https://cli.tigerdata.com", "version_check": true, + "mcp_max_rows": config.DefaultMCPMaxRows, } for key, expectedValue := range expectedValues { diff --git a/internal/tiger/config/config.go b/internal/tiger/config/config.go index e34a1db9..72334b35 100644 --- a/internal/tiger/config/config.go +++ b/internal/tiger/config/config.go @@ -26,6 +26,7 @@ type Config struct { DocsMCP bool `mapstructure:"docs_mcp"` DocsMCPURL string `mapstructure:"docs_mcp_url"` GatewayURL string `mapstructure:"gateway_url"` + MCPMaxRows int `mapstructure:"mcp_max_rows"` Output string `mapstructure:"output"` PasswordStorage string `mapstructure:"password_storage"` ReadOnly bool `mapstructure:"read_only"` @@ -45,6 +46,7 @@ type ConfigOutput struct { DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` + MCPMaxRows *int `mapstructure:"mcp_max_rows" json:"mcp_max_rows,omitempty"` Output *string `mapstructure:"output" json:"output,omitempty"` PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` ReadOnly *bool `mapstructure:"read_only" json:"read_only,omitempty"` @@ -63,6 +65,7 @@ const ( DefaultDocsMCP = true DefaultDocsMCPURL = "https://mcp.tigerdata.com/docs?disabled_skills=ghost-database" DefaultGatewayURL = "https://console.cloud.tigerdata.com/api" + DefaultMCPMaxRows = 100 DefaultOutput = "table" DefaultPasswordStorage = "keyring" DefaultReadOnly = false @@ -84,6 +87,7 @@ var defaultValues = map[string]any{ "docs_mcp": DefaultDocsMCP, "docs_mcp_url": DefaultDocsMCPURL, "gateway_url": DefaultGatewayURL, + "mcp_max_rows": DefaultMCPMaxRows, "output": DefaultOutput, "password_storage": DefaultPasswordStorage, "read_only": DefaultReadOnly, @@ -282,6 +286,14 @@ func setBool(key, val string) (bool, error) { return b, nil } +func setInt(key, val string) (int, error) { + n, err := strconv.Atoi(val) + if err != nil { + return 0, fmt.Errorf("invalid %s value: %s (must be an integer)", key, val) + } + return n, nil +} + // UpdateField updates the field in the Config struct corresponding to the given key. // It accepts either a string (from user input) or a typed value (string/bool from defaults). // 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) { return nil, fmt.Errorf("version_check must be string or bool, got %T", value) } + case "mcp_max_rows": + var n int + switch v := value.(type) { + case int: + n = v + case string: + parsed, err := setInt("mcp_max_rows", v) + if err != nil { + return nil, err + } + n = parsed + default: + return nil, fmt.Errorf("mcp_max_rows must be string or int, got %T", value) + } + if n < 1 { + return nil, fmt.Errorf("mcp_max_rows must be at least 1, got %d", n) + } + c.MCPMaxRows = n + validated = n + default: return nil, fmt.Errorf("unknown configuration key: %s", key) } diff --git a/internal/tiger/config/config_test.go b/internal/tiger/config/config_test.go index 594a6a5b..654c9789 100644 --- a/internal/tiger/config/config_test.go +++ b/internal/tiger/config/config_test.go @@ -73,6 +73,9 @@ func TestLoad_DefaultValues(t *testing.T) { if cfg.ReadOnly != DefaultReadOnly { t.Errorf("Expected ReadOnly %t, got %t", DefaultReadOnly, cfg.ReadOnly) } + if cfg.MCPMaxRows != DefaultMCPMaxRows { + t.Errorf("Expected MCPMaxRows %d, got %d", DefaultMCPMaxRows, cfg.MCPMaxRows) + } if cfg.ConfigDir != tmpDir { t.Errorf("Expected ConfigDir %s, got %s", tmpDir, cfg.ConfigDir) } @@ -418,6 +421,28 @@ func TestSet(t *testing.T) { value: "invalid", expectedError: true, }, + { + key: "mcp_max_rows", + value: "250", + checkFunc: func() bool { + return cfg.MCPMaxRows == 250 + }, + }, + { + key: "mcp_max_rows", + value: "0", + expectedError: true, + }, + { + key: "mcp_max_rows", + value: "-5", + expectedError: true, + }, + { + key: "mcp_max_rows", + value: "notanumber", + expectedError: true, + }, { key: "unknown_key", value: "value", diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index a757731c..139e9af5 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -13,10 +13,57 @@ import ( "go.uber.org/zap" "github.com/timescale/tiger-cli/internal/tiger/common" + "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" "github.com/timescale/tiger-cli/internal/tiger/util" ) +const ( + // mcpMaxRowsCeiling is the hard upper bound on rows returned per result set, + // regardless of the configured or per-call max_rows. + mcpMaxRowsCeiling = 10000 + + // mcpMaxResponseBytes caps the total serialized row data across a response, + // guarding the model's context against a few very wide rows that the row cap + // alone would miss. Not user-configurable. + mcpMaxResponseBytes = 256 * 1024 +) + +// resolveMaxRows returns the effective per-result-set row cap: the per-call +// value if > 0, else the configured value, else the default, clamped to +// mcpMaxRowsCeiling. It also sanitizes non-positive config-file/env values +// (which bypass `tiger config set` validation) to the default. +func resolveMaxRows(configured, requested int) int { + n := configured + if requested > 0 { + n = requested + } + if n <= 0 { + n = config.DefaultMCPMaxRows + } + if n > mcpMaxRowsCeiling { + n = mcpMaxRowsCeiling + } + return n +} + +// approxRowSize estimates the serialized size, in bytes, of a single row's +// values. It is used to enforce the overall response byte budget. The estimate +// mirrors how the row is ultimately serialized to JSON for the client. +func approxRowSize(values []any) int { + if b, err := json.Marshal(values); err == nil { + return len(b) + } + // Fallback for the rare value that isn't JSON-marshalable. + return len(fmt.Sprint(values...)) +} + +// truncationNotice builds the actionable guidance returned to the model when a +// response is truncated. +func truncationNotice(maxRows int) string { + 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) +} + // DBExecuteQueryInput represents input for db_execute_query type DBExecuteQueryInput struct { ServiceID string `json:"service_id"` @@ -25,6 +72,7 @@ type DBExecuteQueryInput struct { TimeoutSeconds int `json:"timeout_seconds,omitempty"` Role string `json:"role,omitempty"` Pooled bool `json:"pooled,omitempty"` + MaxRows int `json:"max_rows,omitempty"` } func (DBExecuteQueryInput) Schema() *jsonschema.Schema { @@ -52,6 +100,12 @@ func (DBExecuteQueryInput) Schema() *jsonschema.Schema { schema.Properties["pooled"].Default = util.Must(json.Marshal(false)) schema.Properties["pooled"].Examples = []any{false, true} + // No schema Default: it would be injected by the SDK and shadow the + // configured mcp_max_rows fallback when max_rows is omitted. + 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) + schema.Properties["max_rows"].Minimum = util.Ptr(1.0) + schema.Properties["max_rows"].Examples = []any{100, 500} + return schema } @@ -67,12 +121,15 @@ type ResultSet struct { Columns []DBExecuteQueryColumn `json:"columns,omitempty"` Rows *[][]any `json:"rows,omitempty"` RowsAffected int64 `json:"rows_affected"` + Truncated bool `json:"truncated,omitempty"` } // DBExecuteQueryOutput represents output for db_execute_query type DBExecuteQueryOutput struct { ResultSets []ResultSet `json:"result_sets"` ExecutionTime string `json:"execution_time"` + Truncated bool `json:"truncated,omitempty"` + Notice string `json:"notice,omitempty"` } func (DBExecuteQueryOutput) Schema() *jsonschema.Schema { @@ -96,12 +153,18 @@ func (DBExecuteQueryOutput) Schema() *jsonschema.Schema { resultSetSchema.Properties["rows"].Description = "Result rows as arrays of values. Omitted for commands that don't return rows (INSERT, UPDATE, DELETE, etc.)" resultSetSchema.Properties["rows"].Examples = []any{[][]any{{1, "alice", "2024-01-01"}, {2, "bob", "2024-01-02"}}} - 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)." + 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)." resultSetSchema.Properties["rows_affected"].Examples = []any{5, 42, 1000} + 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." + schema.Properties["execution_time"].Description = "Execution time as a human-readable duration string" schema.Properties["execution_time"].Examples = []any{"123ms", "1.5s", "45.2µs"} + schema.Properties["truncated"].Description = "True when any result set was truncated to limit the amount of data returned. See notice for guidance." + + 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." + return schema } @@ -116,6 +179,10 @@ Connects to a PostgreSQL database service in Tiger Cloud and executes the provid 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. +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. + +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. + WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`, InputSchema: DBExecuteQueryInput{}.Schema(), OutputSchema: DBExecuteQueryOutput{}.Schema(), @@ -308,6 +375,10 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ } defer conn.Close(context.Background()) + // Bound how much data this call returns to the model's context. + maxRows := resolveMaxRows(cfg.MCPMaxRows, input.MaxRows) + remainingBytes := mcpMaxResponseBytes + // Execute query and measure time startTime := time.Now() @@ -324,6 +395,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ // Process all result sets, collecting them all resultSets := make([]ResultSet, 0) + truncated := false for { rows, err := br.Query() if err != nil { @@ -338,17 +410,25 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ return nil, DBExecuteQueryOutput{}, err } - // Process this result set - result, err := processResultSet(conn, rows) + // Process this result set, capping rows and the shared byte budget. + result, err := processResultSet(cancel, conn, rows, maxRows, &remainingBytes) if err != nil { return nil, DBExecuteQueryOutput{}, err } // Collect this result set resultSets = append(resultSets, result) + + if result.Truncated { + // processResultSet already aborted the query (via cancel); stop + // reading the batch. + truncated = true + break + } } - if err := br.Close(); err != nil { + // After an abort, br.Close() returns the expected cancellation error. + if err := br.Close(); err != nil && !truncated { return nil, DBExecuteQueryOutput{}, err } @@ -357,12 +437,19 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ ResultSets: resultSets, ExecutionTime: time.Since(startTime).String(), } + if truncated { + output.Truncated = true + output.Notice = truncationNotice(maxRows) + } return nil, output, nil } -// processResultSet reads all data from a pgx.Rows result set -func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) { +// processResultSet reads a pgx.Rows result set, capping at maxRows and at the +// shared byte budget (remainingBytes). The returned ResultSet.Truncated reports +// whether rows were dropped; on truncation it cancels to abort the query rather +// than let pgx drain the rest. +func processResultSet(cancel context.CancelFunc, conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) { defer rows.Close() // Get column metadata from field descriptions @@ -381,7 +468,7 @@ func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) { } } - // Collect all rows from this result set + // Collect rows from this result set var resultRows [][]any if len(columns) > 0 { // If any columns were returned, initialize resultRows to an empty @@ -392,25 +479,53 @@ func processResultSet(conn *pgx.Conn, rows pgx.Rows) (ResultSet, error) { // so we leave resultRows nil so it gets omitted from the JSON result. resultRows = make([][]any, 0) } + + truncated := false for rows.Next() { + // Row cap: another row exists but we already hold maxRows. + if len(resultRows) >= maxRows { + truncated = true + break + } + // Scan values into generic interface slice values, err := rows.Values() if err != nil { return ResultSet{}, err } + + // Byte safety net for wide rows, but always keep at least one row so an + // oversized first row doesn't yield an empty result. + size := approxRowSize(values) + if len(resultRows) > 0 && *remainingBytes-size < 0 { + truncated = true + break + } + *remainingBytes -= size + resultRows = append(resultRows, values) } - // Check for errors during iteration - if err := rows.Err(); err != nil { + if truncated { + // Cancel before the deferred rows.Close() so pgx aborts the query + // instead of draining the remaining rows. + cancel() + } else if err := rows.Err(); err != nil { return ResultSet{}, err } commandTag := rows.CommandTag() + rowsAffected := commandTag.RowsAffected() + if truncated { + // The command tag is unreliable after an abort; report rows returned. + rowsAffected = int64(len(resultRows)) + } + return ResultSet{ CommandTag: commandTag.String(), Columns: columns, Rows: util.PtrIfNonNil(resultRows), - RowsAffected: commandTag.RowsAffected(), + RowsAffected: rowsAffected, + Truncated: truncated, }, nil } diff --git a/internal/tiger/mcp/db_tools_test.go b/internal/tiger/mcp/db_tools_test.go new file mode 100644 index 00000000..65ff01bc --- /dev/null +++ b/internal/tiger/mcp/db_tools_test.go @@ -0,0 +1,142 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +func TestResolveMaxRows(t *testing.T) { + tests := []struct { + name string + configured int + requested int + want int + }{ + { + name: "both unset falls back to default", + configured: 0, + requested: 0, + want: config.DefaultMCPMaxRows, + }, + { + name: "configured used when no per-call value", + configured: 250, + requested: 0, + want: 250, + }, + { + name: "per-call overrides configured", + configured: 250, + requested: 10, + want: 10, + }, + { + name: "per-call overrides default when configured unset", + configured: 0, + requested: 42, + want: 42, + }, + { + name: "per-call clamped to ceiling", + configured: 100, + requested: mcpMaxRowsCeiling + 5000, + want: mcpMaxRowsCeiling, + }, + { + name: "configured clamped to ceiling", + configured: mcpMaxRowsCeiling + 1, + requested: 0, + want: mcpMaxRowsCeiling, + }, + { + // A config-file or TIGER_MCP_MAX_ROWS value bypasses `tiger config + // set` validation, so a zero (or negative) configured value can + // reach here and must be sanitized to the default. + name: "zero configured (env/file bypass) falls back to default", + configured: 0, + requested: 0, + want: config.DefaultMCPMaxRows, + }, + { + name: "negative configured falls back to default", + configured: -1, + requested: 0, + want: config.DefaultMCPMaxRows, + }, + { + name: "negative per-call value is ignored in favor of configured", + configured: 250, + requested: -5, + want: 250, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveMaxRows(tt.configured, tt.requested); got != tt.want { + t.Errorf("resolveMaxRows(%d, %d) = %d, want %d", tt.configured, tt.requested, got, tt.want) + } + }) + } +} + +func TestApproxRowSize(t *testing.T) { + // A small row should be smaller than a row with a large text value, and + // both should be positive. We don't assert exact byte counts (they track + // JSON encoding), only the ordering and positivity the byte budget relies on. + small := approxRowSize([]any{1, "a"}) + large := approxRowSize([]any{1, strings.Repeat("x", 1000)}) + + if small <= 0 { + t.Errorf("approxRowSize(small) = %d, want > 0", small) + } + if large <= small { + t.Errorf("approxRowSize(large)=%d should exceed approxRowSize(small)=%d", large, small) + } +} + +func TestTruncationNotice(t *testing.T) { + notice := truncationNotice(100) + // The notice must mention the actual cap and steer the model toward doing + // the work in SQL rather than re-running the query. + for _, want := range []string{"100", "LIMIT", "aggregate"} { + if !strings.Contains(notice, want) { + t.Errorf("truncationNotice() = %q, missing %q", notice, want) + } + } +} + +func TestDBExecuteQueryInputSchemaHasMaxRows(t *testing.T) { + schema := DBExecuteQueryInput{}.Schema() + prop, ok := schema.Properties["max_rows"] + if !ok { + t.Fatal("expected max_rows property in input schema") + } + if prop.Description == "" { + t.Error("expected max_rows to have a description") + } + // No default: omitting max_rows must fall back to the configured value, + // not a schema-injected default. + if prop.Default != nil { + t.Errorf("expected max_rows to have no schema default, got %s", string(prop.Default)) + } +} + +func TestDBExecuteQueryOutputSchemaHasTruncationFields(t *testing.T) { + schema := DBExecuteQueryOutput{}.Schema() + for _, name := range []string{"truncated", "notice"} { + prop, ok := schema.Properties[name] + if !ok { + t.Fatalf("expected %q property in output schema", name) + } + if prop.Description == "" { + t.Errorf("expected %q to have a description", name) + } + } + resultSet := schema.Properties["result_sets"].Items + if _, ok := resultSet.Properties["truncated"]; !ok { + t.Error("expected truncated property on result set schema") + } +} diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index 28f53c5f..e792dd8b 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -37,7 +37,9 @@ type Server struct { // gate itself stays correct because handlers reload config per call. func buildServerInstructions(cfg *config.Config) string { base := "Tiger MCP provides tools for creating, managing, and querying Tiger Cloud database services (managed TimescaleDB/PostgreSQL). " + - "Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation." + "Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation. " + + "When working with data, do the work in the database: push aggregation, filtering, sorting, and joins into SQL rather than fetching raw rows and computing locally. " + + "db_execute_query caps the rows and total size it returns; if a result is truncated, refine the query (aggregate, filter, or paginate with LIMIT/OFFSET) instead of re-running it to pull everything." if cfg == nil || !cfg.ReadOnly { return base diff --git a/specs/spec.md b/specs/spec.md index 5d05e538..0d6b8cfe 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -46,6 +46,7 @@ All configuration options can be set via `tiger config set `: - `color` - Enable/disable colored output (default: true) - `debug` - Enable/disable debug logging (default: false) - `docs_mcp` - Enable/disable docs MCP proxy (default: true) +- `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. - `output` - Output format: json, yaml, or table (default: table) - `password_storage` - Password storage method: keyring, pgpass, or none (default: keyring) - `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. diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index 4e31c6d2..318ad2b0 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -350,9 +350,12 @@ Execute a SQL query on a service database. - `timeout_seconds` (number, optional): Query timeout in seconds (default: 30) - `role` (string, optional): Database role/username to connect as (default: tsdbadmin) - `pooled` (boolean, optional): Use connection pooling (default: false) +- `max_rows` (number, optional): Maximum rows to return per result set before truncating. Defaults to the `mcp_max_rows` config value (default 100). Hard-capped at 10000. **Returns:** Query results with rows, columns (including types), rows affected count, and execution metadata. +**Result limiting (context-window protection):** To keep large query results from overflowing an AI agent's context window, results are capped. Each result set returns at most `max_rows` rows (per-call parameter, falling back to the `mcp_max_rows` config value, hard-capped at 10000), and the total serialized row data across all result sets is bounded by a built-in safety net. When a cap is hit, the in-flight query is aborted (so PostgreSQL stops streaming unused rows), the affected result set's `truncated` field is set to `true`, the top-level response sets `truncated: true`, and a `notice` field returns guidance to refine the query in SQL (aggregate, filter, or paginate with `LIMIT`/`OFFSET`) rather than re-running it. The tool description also instructs agents to push computation (aggregation, filtering, sorting, joins) into SQL instead of fetching raw rows. This limiting applies only to the MCP tool, not to CLI commands. + **Example Response:** ```json { @@ -375,6 +378,7 @@ Execute a SQL query on a service database. - `columns` includes both the column name and PostgreSQL data type for each column - Empty `rows` array for commands that don't return rows (INSERT, UPDATE, DELETE, DDL commands) - For parity with `tiger db connect` command, supports custom roles and connection pooling +- `truncated` (per result set and top-level) and `notice` are present only when results were capped; see "Result limiting" above ### High-Availability Management From ebc487a1af380de3a6dfc37bcb9662a0e77a3d71 Mon Sep 17 00:00:00 2001 From: Alexandra Primakina Date: Tue, 23 Jun 2026 11:45:12 +0200 Subject: [PATCH 2/3] 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 --- README.md | 2 +- internal/tiger/cmd/config.go | 3 + internal/tiger/cmd/config_test.go | 2 + internal/tiger/mcp/db_tools.go | 92 ++++++++++------------------- internal/tiger/mcp/db_tools_test.go | 62 +------------------ specs/spec.md | 2 +- specs/spec_mcp.md | 3 +- 7 files changed, 42 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index a816dfbe..0f8ee2a7 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ All configuration options can be set via `tiger config set `: - `color` - Enable/disable colored output (default: `true`) - `debug` - Enable/disable debug logging (default: `false`) - `docs_mcp` - Enable/disable docs MCP proxy (default: `true`) -- `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` +- `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` - `output` - Output format: `json`, `yaml`, or `table` (default: `table`) - `password_storage` - Password storage method: `keyring`, `pgpass`, or `none` (default: `keyring`) - `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`. diff --git a/internal/tiger/cmd/config.go b/internal/tiger/cmd/config.go index afff70b8..d699cf1d 100644 --- a/internal/tiger/cmd/config.go +++ b/internal/tiger/cmd/config.go @@ -203,6 +203,9 @@ func outputTable(w io.Writer, cfg *config.ConfigOutput) error { if cfg.GatewayURL != nil { table.Append("gateway_url", *cfg.GatewayURL) } + if cfg.MCPMaxRows != nil { + table.Append("mcp_max_rows", fmt.Sprintf("%d", *cfg.MCPMaxRows)) + } if cfg.Color != nil { table.Append("color", fmt.Sprintf("%t", *cfg.Color)) } diff --git a/internal/tiger/cmd/config_test.go b/internal/tiger/cmd/config_test.go index 6ae41038..b73b9176 100644 --- a/internal/tiger/cmd/config_test.go +++ b/internal/tiger/cmd/config_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "slices" + "strconv" "strings" "testing" @@ -101,6 +102,7 @@ password_storage: pgpass "password_storage": "pgpass", "debug": "false", "config_dir": tmpDir, + "mcp_max_rows": strconv.Itoa(config.DefaultMCPMaxRows), } for key, expectedLine := range expectedLines { diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index 139e9af5..246a6688 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -19,37 +19,22 @@ import ( ) const ( - // mcpMaxRowsCeiling is the hard upper bound on rows returned per result set, - // regardless of the configured or per-call max_rows. - mcpMaxRowsCeiling = 10000 - - // mcpMaxResponseBytes caps the total serialized row data across a response, - // guarding the model's context against a few very wide rows that the row cap - // alone would miss. Not user-configurable. + // mcpMaxResponseBytes caps total serialized row data per response, catching a + // few very wide rows the row cap alone would miss. Not user-configurable. mcpMaxResponseBytes = 256 * 1024 ) -// resolveMaxRows returns the effective per-result-set row cap: the per-call -// value if > 0, else the configured value, else the default, clamped to -// mcpMaxRowsCeiling. It also sanitizes non-positive config-file/env values -// (which bypass `tiger config set` validation) to the default. -func resolveMaxRows(configured, requested int) int { - n := configured - if requested > 0 { - n = requested - } - if n <= 0 { - n = config.DefaultMCPMaxRows +// resolveMaxRows returns the row cap from mcp_max_rows, falling back to the +// default for non-positive config-file/env values (which skip set validation). +func resolveMaxRows(configured int) int { + if configured <= 0 { + return config.DefaultMCPMaxRows } - if n > mcpMaxRowsCeiling { - n = mcpMaxRowsCeiling - } - return n + return configured } -// approxRowSize estimates the serialized size, in bytes, of a single row's -// values. It is used to enforce the overall response byte budget. The estimate -// mirrors how the row is ultimately serialized to JSON for the client. +// approxRowSize estimates a row's serialized size in bytes for the byte budget, +// mirroring how it is ultimately marshaled to JSON for the client. func approxRowSize(values []any) int { if b, err := json.Marshal(values); err == nil { return len(b) @@ -61,7 +46,7 @@ func approxRowSize(values []any) int { // truncationNotice builds the actionable guidance returned to the model when a // response is truncated. func truncationNotice(maxRows int) string { - 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) + 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) } // DBExecuteQueryInput represents input for db_execute_query @@ -72,7 +57,6 @@ type DBExecuteQueryInput struct { TimeoutSeconds int `json:"timeout_seconds,omitempty"` Role string `json:"role,omitempty"` Pooled bool `json:"pooled,omitempty"` - MaxRows int `json:"max_rows,omitempty"` } func (DBExecuteQueryInput) Schema() *jsonschema.Schema { @@ -100,12 +84,6 @@ func (DBExecuteQueryInput) Schema() *jsonschema.Schema { schema.Properties["pooled"].Default = util.Must(json.Marshal(false)) schema.Properties["pooled"].Examples = []any{false, true} - // No schema Default: it would be injected by the SDK and shadow the - // configured mcp_max_rows fallback when max_rows is omitted. - 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) - schema.Properties["max_rows"].Minimum = util.Ptr(1.0) - schema.Properties["max_rows"].Examples = []any{100, 500} - return schema } @@ -153,10 +131,10 @@ func (DBExecuteQueryOutput) Schema() *jsonschema.Schema { resultSetSchema.Properties["rows"].Description = "Result rows as arrays of values. Omitted for commands that don't return rows (INSERT, UPDATE, DELETE, etc.)" resultSetSchema.Properties["rows"].Examples = []any{[][]any{{1, "alice", "2024-01-01"}, {2, "bob", "2024-01-02"}}} - 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)." + 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)." resultSetSchema.Properties["rows_affected"].Examples = []any{5, 42, 1000} - 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." + 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." schema.Properties["execution_time"].Description = "Execution time as a human-readable duration string" 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 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. -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. - -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. +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. WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`, InputSchema: DBExecuteQueryInput{}.Schema(), @@ -376,7 +352,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ defer conn.Close(context.Background()) // Bound how much data this call returns to the model's context. - maxRows := resolveMaxRows(cfg.MCPMaxRows, input.MaxRows) + maxRows := resolveMaxRows(cfg.MCPMaxRows) remainingBytes := mcpMaxResponseBytes // Execute query and measure time @@ -411,7 +387,7 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ } // Process this result set, capping rows and the shared byte budget. - result, err := processResultSet(cancel, conn, rows, maxRows, &remainingBytes) + result, err := processResultSet(conn, rows, maxRows, &remainingBytes) if err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -420,15 +396,15 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ resultSets = append(resultSets, result) if result.Truncated { - // processResultSet already aborted the query (via cancel); stop - // reading the batch. + // Stop reading further sets; br.Close() below discards them. The + // query isn't cancelled, so all statements still run server-side. truncated = true break } } - // After an abort, br.Close() returns the expected cancellation error. - if err := br.Close(); err != nil && !truncated { + // Close the batch, discarding any result sets we didn't read. + if err := br.Close(); err != nil { return nil, DBExecuteQueryOutput{}, err } @@ -445,11 +421,9 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ return nil, output, nil } -// processResultSet reads a pgx.Rows result set, capping at maxRows and at the -// shared byte budget (remainingBytes). The returned ResultSet.Truncated reports -// whether rows were dropped; on truncation it cancels to abort the query rather -// than let pgx drain the rest. -func processResultSet(cancel context.CancelFunc, conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) { +// processResultSet reads a result set, capping at maxRows and the shared byte +// budget. ResultSet.Truncated reports whether rows were dropped. +func processResultSet(conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes *int) (ResultSet, error) { defer rows.Close() // Get column metadata from field descriptions @@ -496,36 +470,32 @@ func processResultSet(cancel context.CancelFunc, conn *pgx.Conn, rows pgx.Rows, // Byte safety net for wide rows, but always keep at least one row so an // oversized first row doesn't yield an empty result. - size := approxRowSize(values) - if len(resultRows) > 0 && *remainingBytes-size < 0 { + remaining := *remainingBytes - approxRowSize(values) + if len(resultRows) > 0 && remaining < 0 { truncated = true break } - *remainingBytes -= size + *remainingBytes = remaining resultRows = append(resultRows, values) } if truncated { - // Cancel before the deferred rows.Close() so pgx aborts the query - // instead of draining the remaining rows. - cancel() + // Drain so the command tag (and true row count) is available. + rows.Close() } else if err := rows.Err(); err != nil { return ResultSet{}, err } + // After a full drain the command tag reports the true row count, even when + // we returned fewer. commandTag := rows.CommandTag() - rowsAffected := commandTag.RowsAffected() - if truncated { - // The command tag is unreliable after an abort; report rows returned. - rowsAffected = int64(len(resultRows)) - } return ResultSet{ CommandTag: commandTag.String(), Columns: columns, Rows: util.PtrIfNonNil(resultRows), - RowsAffected: rowsAffected, + RowsAffected: commandTag.RowsAffected(), Truncated: truncated, }, nil } diff --git a/internal/tiger/mcp/db_tools_test.go b/internal/tiger/mcp/db_tools_test.go index 65ff01bc..039e5542 100644 --- a/internal/tiger/mcp/db_tools_test.go +++ b/internal/tiger/mcp/db_tools_test.go @@ -11,72 +11,32 @@ func TestResolveMaxRows(t *testing.T) { tests := []struct { name string configured int - requested int want int }{ { - name: "both unset falls back to default", - configured: 0, - requested: 0, - want: config.DefaultMCPMaxRows, - }, - { - name: "configured used when no per-call value", + name: "configured value is used", configured: 250, - requested: 0, want: 250, }, - { - name: "per-call overrides configured", - configured: 250, - requested: 10, - want: 10, - }, - { - name: "per-call overrides default when configured unset", - configured: 0, - requested: 42, - want: 42, - }, - { - name: "per-call clamped to ceiling", - configured: 100, - requested: mcpMaxRowsCeiling + 5000, - want: mcpMaxRowsCeiling, - }, - { - name: "configured clamped to ceiling", - configured: mcpMaxRowsCeiling + 1, - requested: 0, - want: mcpMaxRowsCeiling, - }, { // A config-file or TIGER_MCP_MAX_ROWS value bypasses `tiger config // set` validation, so a zero (or negative) configured value can // reach here and must be sanitized to the default. name: "zero configured (env/file bypass) falls back to default", configured: 0, - requested: 0, want: config.DefaultMCPMaxRows, }, { name: "negative configured falls back to default", configured: -1, - requested: 0, want: config.DefaultMCPMaxRows, }, - { - name: "negative per-call value is ignored in favor of configured", - configured: 250, - requested: -5, - want: 250, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := resolveMaxRows(tt.configured, tt.requested); got != tt.want { - t.Errorf("resolveMaxRows(%d, %d) = %d, want %d", tt.configured, tt.requested, got, tt.want) + if got := resolveMaxRows(tt.configured); got != tt.want { + t.Errorf("resolveMaxRows(%d) = %d, want %d", tt.configured, got, tt.want) } }) } @@ -108,22 +68,6 @@ func TestTruncationNotice(t *testing.T) { } } -func TestDBExecuteQueryInputSchemaHasMaxRows(t *testing.T) { - schema := DBExecuteQueryInput{}.Schema() - prop, ok := schema.Properties["max_rows"] - if !ok { - t.Fatal("expected max_rows property in input schema") - } - if prop.Description == "" { - t.Error("expected max_rows to have a description") - } - // No default: omitting max_rows must fall back to the configured value, - // not a schema-injected default. - if prop.Default != nil { - t.Errorf("expected max_rows to have no schema default, got %s", string(prop.Default)) - } -} - func TestDBExecuteQueryOutputSchemaHasTruncationFields(t *testing.T) { schema := DBExecuteQueryOutput{}.Schema() for _, name := range []string{"truncated", "notice"} { diff --git a/specs/spec.md b/specs/spec.md index 0d6b8cfe..5bd9c951 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -46,7 +46,7 @@ All configuration options can be set via `tiger config set `: - `color` - Enable/disable colored output (default: true) - `debug` - Enable/disable debug logging (default: false) - `docs_mcp` - Enable/disable docs MCP proxy (default: true) -- `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. +- `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. - `output` - Output format: json, yaml, or table (default: table) - `password_storage` - Password storage method: keyring, pgpass, or none (default: keyring) - `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. diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index 318ad2b0..72c0b6a1 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -350,11 +350,10 @@ Execute a SQL query on a service database. - `timeout_seconds` (number, optional): Query timeout in seconds (default: 30) - `role` (string, optional): Database role/username to connect as (default: tsdbadmin) - `pooled` (boolean, optional): Use connection pooling (default: false) -- `max_rows` (number, optional): Maximum rows to return per result set before truncating. Defaults to the `mcp_max_rows` config value (default 100). Hard-capped at 10000. **Returns:** Query results with rows, columns (including types), rows affected count, and execution metadata. -**Result limiting (context-window protection):** To keep large query results from overflowing an AI agent's context window, results are capped. Each result set returns at most `max_rows` rows (per-call parameter, falling back to the `mcp_max_rows` config value, hard-capped at 10000), and the total serialized row data across all result sets is bounded by a built-in safety net. When a cap is hit, the in-flight query is aborted (so PostgreSQL stops streaming unused rows), the affected result set's `truncated` field is set to `true`, the top-level response sets `truncated: true`, and a `notice` field returns guidance to refine the query in SQL (aggregate, filter, or paginate with `LIMIT`/`OFFSET`) rather than re-running it. The tool description also instructs agents to push computation (aggregation, filtering, sorting, joins) into SQL instead of fetching raw rows. This limiting applies only to the MCP tool, not to CLI commands. +**Result limiting (context-window protection):** To keep large query results from overflowing an AI agent's context window, results are capped. Each result set returns at most the configured `mcp_max_rows` rows (default 100), and the total serialized row data across all result sets is bounded by a built-in safety net. When a cap is hit, the affected result set's `truncated` field is set to `true`, the top-level response sets `truncated: true`, and a `notice` field returns guidance to refine the query in SQL (aggregate, filter, or paginate with `LIMIT`/`OFFSET`) rather than re-running it. The query is not cancelled, so any writes and later statements in a multi-statement query still complete, and the result set's `rows_affected` reports the true number of rows the query produced even when fewer are returned. The tool description also instructs agents to push computation (aggregation, filtering, sorting, joins) into SQL instead of fetching raw rows. This limiting applies only to the MCP tool, not to CLI commands. **Example Response:** ```json From b6490adaf7d120a6a929da6b2d929a497e746685 Mon Sep 17 00:00:00 2001 From: Alexandra Primakina Date: Thu, 25 Jun 2026 13:23:05 +0200 Subject: [PATCH 3/3] refactor(mcp): trim duplicative truncation docs, simplify result drain Drop the db_execute_query-specific truncation sentence from the top-level server instructions and the redundant cap/truncation mechanics from the tool description; the truncated and notice output field descriptions already cover them. In processResultSet, close rows and check rows.Err() unconditionally so errors that surface after scanning some rows aren't missed. Co-Authored-By: Claude Opus 4.8 --- internal/tiger/mcp/db_tools.go | 12 +++++------- internal/tiger/mcp/server.go | 4 +--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index 246a6688..31a27b67 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -157,7 +157,7 @@ Connects to a PostgreSQL database service in Tiger Cloud and executes the provid 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. -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. +Process data in the database, not in your context: aggregate, filter, sort/limit, and join in SQL rather than fetching raw rows. WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL commands. Always review queries before execution.`, InputSchema: DBExecuteQueryInput{}.Schema(), @@ -480,15 +480,13 @@ func processResultSet(conn *pgx.Conn, rows pgx.Rows, maxRows int, remainingBytes resultRows = append(resultRows, values) } - if truncated { - // Drain so the command tag (and true row count) is available. - rows.Close() - } else if err := rows.Err(); err != nil { + // Drain so the command tag reports the true row count even when truncated. + rows.Close() + + if err := rows.Err(); err != nil { return ResultSet{}, err } - // After a full drain the command tag reports the true row count, even when - // we returned fewer. commandTag := rows.CommandTag() return ResultSet{ diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index e792dd8b..28f53c5f 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -37,9 +37,7 @@ type Server struct { // gate itself stays correct because handlers reload config per call. func buildServerInstructions(cfg *config.Config) string { base := "Tiger MCP provides tools for creating, managing, and querying Tiger Cloud database services (managed TimescaleDB/PostgreSQL). " + - "Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation. " + - "When working with data, do the work in the database: push aggregation, filtering, sorting, and joins into SQL rather than fetching raw rows and computing locally. " + - "db_execute_query caps the rows and total size it returns; if a result is truncated, refine the query (aggregate, filter, or paginate with LIMIT/OFFSET) instead of re-running it to pull everything." + "Use it to provision and fork services, start/stop/resize instances, rotate credentials, fetch service logs, execute SQL queries, and search Tiger documentation." if cfg == nil || !cfg.ReadOnly { return base