Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/xsql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ For long-running queries, set a timeout (default: 30s):
xsql query "<SQL>" -p <profile> -f json --query-timeout 60 --attr source=codex-cli --attr agent=codex --attr env=<env> --attr task=long-query
```

Results are capped at **1000 rows** by default (`--max-rows`; MCP default **500**). Use explicit `LIMIT` in SQL for pagination. If `data.truncated` is true, follow `data.hint`. Pass `--max-rows 0` only when you truly need an unlimited result set.

### Step 4: Validate the response

Check the JSON envelope:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Note: Read-only mode by default. Writes require both profile `unsafe_allow_write
| Command | Description |
|---------|-------------|
| `xsql ai [PROMPT]` | Start interactive AI assistant mode (TUI) |
| `xsql query <SQL>` | Execute SQL queries (read-only by default) |
| `xsql query <SQL>` | Execute SQL queries (read-only by default; 1000-row default cap, `--max-rows`) |
| `xsql schema dump` | Export database schema (tables, columns, indexes, foreign keys) |
| `xsql profile list` | List all profiles |
| `xsql profile show <name>` | Show profile details (passwords are masked) |
Expand Down
2 changes: 1 addition & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ xsql query "<SQL>" -p <profile> -f json --attr source=codex-cli --attr agent=cod
| 命令 | 说明 |
|------|------|
| `xsql ai [PROMPT]` | 启动全屏交互式 AI 终端模式(TUI) |
| `xsql query <SQL>` | 执行 SQL 查询(默认只读) |
| `xsql query <SQL>` | 执行 SQL 查询(默认只读;默认最多 1000 行,`--max-rows` 可调) |
| `xsql schema dump` | 导出数据库结构(表、列、索引、外键) |
| `xsql profile list` | 列出所有 profile |
| `xsql profile show <name>` | 查看 profile 详情(密码脱敏) |
Expand Down
11 changes: 11 additions & 0 deletions cmd/xsql/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"os"
"time"

"github.com/spf13/cobra"
Expand All @@ -24,6 +25,8 @@ type QueryFlags struct {
SSHSkipHostKey bool
QueryTimeout int
QueryTimeoutSet bool
MaxRows int
MaxRowsSet bool
}

// NewQueryCommand creates the query command
Expand All @@ -36,6 +39,7 @@ func NewQueryCommand(w *output.Writer) *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
flags.QueryTimeoutSet = cmd.Flags().Changed("query-timeout")
flags.MaxRowsSet = cmd.Flags().Changed("max-rows")
return runQuery(args, flags, w)
},
}
Expand All @@ -44,6 +48,7 @@ func NewQueryCommand(w *output.Writer) *cobra.Command {
cmd.Flags().BoolVar(&flags.AllowPlaintext, "allow-plaintext", false, "Allow plaintext secrets in config")
cmd.Flags().BoolVar(&flags.SSHSkipHostKey, "ssh-skip-known-hosts-check", false, "Skip SSH known_hosts check (dangerous)")
cmd.Flags().IntVar(&flags.QueryTimeout, "query-timeout", 0, "Query timeout in seconds (default: 30)")
cmd.Flags().IntVar(&flags.MaxRows, "max-rows", 0, "Maximum rows to return (default: 1000; 0 = unlimited)")

return cmd
}
Expand All @@ -57,6 +62,10 @@ func runQuery(args []string, flags *QueryFlags, w *output.Writer) error {
}

p := GlobalConfig.Resolved.Profile
maxRows, xe := app.ResolveQueryMaxRows(p, os.Getenv("XSQL_MAX_ROWS"), flags.MaxRows, flags.MaxRowsSet)
if xe != nil {
return xe
}
timeout := app.QueryTimeout(p, flags.QueryTimeout, flags.QueryTimeoutSet, DefaultQueryTimeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
Expand All @@ -68,6 +77,8 @@ func runQuery(args []string, flags *QueryFlags, w *output.Writer) error {
AllowPlaintext: flags.AllowPlaintext,
SkipHostKeyCheck: flags.SSHSkipHostKey,
UnsafeAllowWrite: cliWriteAllowed(flags.UnsafeAllowWrite, p.UnsafeAllowWrite),
MaxRows: maxRows,
TruncationHint: app.QueryTruncationHint,
})

// Record stats
Expand Down
2 changes: 1 addition & 1 deletion docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au

### MCP Tools
MCP Server 提供以下 tools(均经 `internal/app`,SSH/连接行为与 CLI/Web 一致):
- **query**: 执行 SQL 查询(默认只读;写入需 profile `unsafe_allow_write: true` 且 server 启动时 `--unsafe-allow-write`)
- **query**: 执行 SQL 查询(默认只读;写入需 profile `unsafe_allow_write: true` 且 server 启动时 `--unsafe-allow-write`)。可选 `max_rows`(`0` = 默认;受 `mcp.max_rows_limit` clamp;默认 500 或 profile/mcp 配置)。截断时 `data` 含 `truncated`/`truncated_reason`/`max_rows`/`max_response_bytes`/`hint` 等。
- **schema_list_tables**: 轻量表清单(可选 `table_pattern`、`include_system`),适合大库先做 discovery
- **schema_dump**: 与 `xsql schema dump` 等价的结构化导出(建议配合 `table_pattern` 控制体积)
- **schema_describe_table**: 单表结构(列、索引、外键);PostgreSQL 需 `schema`,MySQL 可省略
Expand Down
26 changes: 24 additions & 2 deletions docs/cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ xsql query "INSERT INTO logs (msg) VALUES ('test')" --profile dev --unsafe-allow
| `--unsafe-allow-write` | false | 本次命令申请写入;仅当 profile 同时设置 `unsafe_allow_write: true` 时生效 |
| `--allow-plaintext` | false | 允许配置中使用明文密码(也可在配置文件中设置 `allow_plaintext: true`) |
| `--ssh-skip-known-hosts-check` | false | 跳过 SSH 主机密钥验证(危险) |
| `--max-rows` | 1000 | 最多返回行数(`0` = 不限;ENV:`XSQL_MAX_ROWS`;可被 profile `max_rows` 覆盖默认值) |
| `--query-timeout` | 30 | 查询超时(秒);profile 可用 `query_timeout` 覆盖 |

**输出示例(JSON):**
```json
Expand All @@ -90,6 +92,23 @@ xsql query "INSERT INTO logs (msg) VALUES ('test')" --profile dev --unsafe-allow
}
```

**截断示例(JSON,字段只增):**
```json
{
"ok": true,
"schema_version": 1,
"data": {
"columns": ["n"],
"rows": [{"n": 1}, {"n": 2}],
"truncated": true,
"row_count": 2,
"max_rows": 2,
"truncated_reason": "max_rows",
"hint": "Result truncated by max_rows; add SQL LIMIT/OFFSET, use aggregation, or run MCP `schema_list_tables` or `xsql schema dump` for schema discovery."
}
}
```

**输出示例(Table):**
```
id name
Expand All @@ -107,7 +126,7 @@ id,name
2,Bob
```

> 注:Table 和 CSV 格式不包含 `ok` 和 `schema_version` 元数据,直接输出数据。
> 注:Table 和 CSV 格式不包含 `ok` 和 `schema_version` 元数据,直接输出数据。CSV 若结果被截断,会在 **stderr** 打印一行截断说明与 hint(stdout 仍为纯 CSV,退出码 0)。

### `xsql schema dump`

Expand Down Expand Up @@ -273,6 +292,8 @@ Web UI 使用以下 HTTP API,统一返回 JSON Envelope:

Web 查询强制只读,即使 profile 配置了 `unsafe_allow_write: true`,也不会在 Web 接口中生效。

`POST /api/v1/query` JSON body:`{"profile":"...","sql":"...","max_rows":1000}`(`max_rows` 可选;省略时使用 profile / `XSQL_MAX_ROWS` / 默认 1000;`0` = 不限)。

**配置 API 脱敏**:`GET /api/v1/config` 与 `GET /api/v1/profiles/{name}` 不会在响应中返回明文密码、DSN、SSH passphrase、AI `api_key` 或 HTTP `auth_token`。明文值以占位符 `***` 表示;`keyring:` 引用会原样返回(不解析 keyring 内容)。通过 Web UI 保存配置时,若客户端未修改某敏感字段(提交空值或 `***`),服务端会保留磁盘上已有值,而不会用占位符覆盖。

> **安全说明**:绑定 loopback 时 Web 默认不要求 Bearer token,本机其他进程仍可能调用上述只读配置接口获取脱敏后的配置与 keyring 引用。生产或共享环境请启用 `web.http.auth_token` 或非 loopback 绑定策略(见 `docs/config.md`)。
Expand Down Expand Up @@ -544,7 +565,8 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SQL query to execute"},
"profile": {"type": "string", "description": "Profile name to use"}
"profile": {"type": "string", "description": "Profile name to use"},
"max_rows": {"type": "integer", "description": "Maximum rows to return (0 = defaults; clamped by mcp.max_rows_limit; MCP default 500)"}
},
"required": ["sql", "profile"]
}
Expand Down
5 changes: 5 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ profiles:
| `mcp.http.addr` | string | Streamable HTTP 监听地址 |
| `mcp.http.auth_token` | string | Streamable HTTP 鉴权 token(支持 `keyring:` 引用) |
| `mcp.http.allow_plaintext_token` | bool | 允许在配置中使用明文 token |
| `mcp.max_rows` | int | MCP `query` tool 默认行数上限(默认 500;profile `max_rows` 优先) |
| `mcp.max_rows_limit` | int | MCP `max_rows` 硬上限(默认 10000;更大请求会被 clamp) |
| `mcp.max_response_bytes` | int | MCP query 响应 JSON 体积上限(默认 524288) |

Profile 还可设置 `max_rows`(query 默认行数上限,CLI/Web 默认 1000)、`query_timeout`、`schema_timeout`。

## AI 配置项

Expand Down
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- `XSQL_AI_MODEL`:AI 模型名称
- `XSQL_AI_BASE_URL`:OpenAI 兼容服务 Base URL
- `XSQL_AI_API_KEY`:AI 服务 API Key(明文仅保存在当前进程环境中)
- `XSQL_MAX_ROWS`:query 默认最大返回行数(非负整数;`0` = 不限;优先级低于 CLI `--max-rows`)

## 2. 连接参数(计划中,当前未实现)
> 当前版本连接参数通过 config 文件的 profile 配置,ENV 支持计划在后续版本实现。
Expand Down
15 changes: 13 additions & 2 deletions docs/error-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ DB 类:
}
```

当结果被截断时,`data` 可附加(`schema_version` 仍为 `1`):
- `truncated`(bool)
- `row_count`(int,已返回行数)
- `max_rows`(int,**生效的配置行数上限**,非当前行数)
- `truncated_reason`(string:`max_rows` 和/或 `max_response_bytes`)
- `max_response_bytes`(int,MCP 字节护栏生效时的阈值)
- `max_rows_requested` / `max_rows_limit`(MCP 请求被 clamp 时)
- `hint`(string,与 `truncated_reason` 一致;多原因时合并)

### Table 格式(人类可读)
```
id name email
Expand Down Expand Up @@ -146,5 +155,7 @@ id,name,email
| 终端查看 | table (auto) |
| 数据导出 | csv |

## 7. 大结果集(计划中)
- `--jsonl`(NDJSON):每行一个 JSON 对象,用于大结果集/流式消费
## 7. 大结果集
- 默认对 query 结果施加 `max_rows`(CLI/Web 默认 1000;MCP 默认 500),扫描阶段提前停止。
- CLI/Web 可用 `--max-rows 0` 或请求体 `max_rows: 0` 显式取消上限。
- 未来可选:`--jsonl`(NDJSON)流式消费。
69 changes: 69 additions & 0 deletions docs/rfcs/0017-query-result-size-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# RFC 0017: Query result size limits

Status: Accepted

## 摘要

为 `xsql query`(CLI / Web / MCP / TUI)增加默认行数上限,在数据库驱动层扫描时提前停止,避免大结果集 OOM 与 MCP/agent 上下文膨胀。截断时在成功响应中附加 `truncated`、`row_count`、`max_rows` 与 agent 提示。CLI/Web 允许 `--max-rows 0` 显式取消上限;MCP 保持强制上限。顺带修复 schema 工具的空 `tables` 与 `include_system` 行为。

## 背景 / 动机

- `internal/db/query.go` 的 `scanRows` 无上限,整表 `SELECT *` 可拖垮进程。
- MCP 将完整 JSON 放入 tool 文本,成本高于 TUI 的 `BoundToolFeedback`。
- PR #82 后 MCP query 与 CLI 共用 `app.Query`,可在单一路径扩展。

## 方案(Proposed)

### 默认与优先级

| 场景 | 默认上限 | 取消上限 |
|------|----------|----------|
| CLI / Web / TUI | 1000 行 | `--max-rows 0` 或 API `max_rows: 0`(须显式) |
| MCP | 500 行 | 不支持无上限;`max_rows: 0` 视为未设置,走默认链 |

MCP 硬上限:**`mcp.max_rows_limit`(默认 10000)**,超过则 clamp 并输出 `max_rows_requested` / `max_rows_limit`。

优先级:**CLI/API 参数 > ENV (`XSQL_MAX_ROWS`) > profile `max_rows` > MCP `mcp.max_rows`(仅 MCP)> 上表默认值**。

选用 1000:足够日常探查,且单行 JSON 通常仍可控;MCP 500 更保守,减少 tool 响应 token。

### 输出(`schema_version` 仍为 1)

成功时 `data` 在既有 `columns`/`rows` 上**只增**字段:

```json
{
"truncated": true,
"row_count": 1000,
"max_rows": 1000,
"truncated_reason": "max_rows",
"hint": "Result truncated by max_rows; add SQL LIMIT/OFFSET, use aggregation, or run MCP `schema_list_tables` or `xsql schema dump` for schema discovery."
}
```

字节护栏截断时追加 `truncated_reason: "max_response_bytes"`(可与 `max_rows` 并存)、`max_response_bytes`,且 **`max_rows` 始终为生效的配置行数上限**(不是当前行数)。`hint` 与 `truncated_reason` 一致(两种原因均出现时 hint 合并两句)。

未截断时不输出 `truncated`/`hint`(`row_count` 可选,与现有客户端兼容)。

### CSV

stdout 仍为纯 CSV;若截断,在 **stderr** 输出一行 `xsql: query result truncated (...)` + hint,**退出码仍为 0**。

### MCP 响应字节护栏(第二层)

配置 `mcp.max_response_bytes`(默认 524288)。序列化后若超限,用二分查找适配行数;metadata 见上。

### Schema 修复

- 空库 `schema_list_tables` 的 `tables` 统一为 `[]`。
- `include_system: true` 时包含 MySQL 系统库表与 PostgreSQL `pg_catalog` / `information_schema` 等(与 RFC 0005 一致)。

## 兼容性与迁移

- 破坏性:默认新增 1000 行 cap;依赖全量无 LIMIT 查询的脚本需 `--max-rows 0` 或调大 profile。
- `schema_version` 不变。

## 测试计划

- 单元:`ResolveQueryMaxRows`、`scanRows` 提前停止(sqlmock)、MCP 字节裁剪。
- 集成 / E2E:MySQL 8.0、PostgreSQL 16 验证默认、自定义、无限、截断元数据。
1 change: 1 addition & 0 deletions docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| [0014](0014-mcp-write-dual-authorization.md) | MCP 写入双重授权 | Implemented |
| [0015](0015-mcp-app-layer-schema-tools.md) | MCP 复用 app 层与 Schema Tools | Implemented |
| [0016](0016-error-guidance-for-agents.md) | AI Agent 错误指引(Error Guidance) | Accepted |
| [0017](0017-query-result-size-limits.md) | Query 结果行数/体积上限 | Accepted |

## 何时需要 RFC
当变更会影响以下任一项时,必须先提交/更新 RFC(并同步更新对应 `docs/*`):
Expand Down
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ func (a App) BuildSpec() spec.Spec {
spec.FlagSpec{Name: "unsafe-allow-write", Default: "false", Description: "Allow writes when profile unsafe_allow_write is true"},
spec.FlagSpec{Name: "allow-plaintext", Default: "false", Description: "Allow plaintext secrets in config"},
spec.FlagSpec{Name: "ssh-skip-known-hosts-check", Default: "false", Description: "Skip SSH known_hosts check (dangerous)"},
spec.FlagSpec{Name: "max-rows", Env: "XSQL_MAX_ROWS", Default: "1000", Description: "Maximum rows to return (0 = unlimited)"},
spec.FlagSpec{Name: "query-timeout", Default: "30", Description: "Query timeout in seconds"},
),
},
{
Expand Down
107 changes: 107 additions & 0 deletions internal/app/max_rows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package app

import (
"strconv"
"strings"

"github.com/zx06/xsql/internal/config"
"github.com/zx06/xsql/internal/errors"
)

const (
// DefaultQueryMaxRows is the default row cap for CLI, Web, and TUI queries.
DefaultQueryMaxRows = 1000
// DefaultMCPQueryMaxRows is a lower default for MCP tool responses (smaller agent payloads).
DefaultMCPQueryMaxRows = 500
// DefaultMCPMaxRowsLimit is the hard ceiling for MCP query max_rows (requests above are clamped).
DefaultMCPMaxRowsLimit = 10000
// DefaultMCPMaxResponseBytes is the default serialized JSON size cap for MCP query results.
DefaultMCPMaxResponseBytes = 512 * 1024
)

// QueryTruncationHint is returned when results are truncated for agents/humans.
const QueryTruncationHint = errors.HintQueryResultTruncated

// MCPResponseTruncationHint is appended when MCP byte guard trims rows.
const MCPResponseTruncationHint = errors.HintMCPResponseTruncated

// MCPMaxRowsMeta describes MCP max_rows resolution/clamping for response metadata.
type MCPMaxRowsMeta struct {
Requested int
Clamped bool
Limit int
}

// ResolveQueryMaxRows returns the effective row limit for CLI/Web/TUI.
// limit 0 means unlimited. Negative values are invalid.
func ResolveQueryMaxRows(profile config.Profile, envMaxRows string, cliValue int, cliSet bool) (limit int, xe *errors.XError) {
if cliSet {
if cliValue < 0 {
return 0, errors.New(errors.CodeCfgInvalid, "max_rows must be >= 0", map[string]any{"max_rows": cliValue})
}
return cliValue, nil
}
if strings.TrimSpace(envMaxRows) != "" {
v, err := strconv.Atoi(strings.TrimSpace(envMaxRows))
if err != nil || v < 0 {
return 0, errors.New(errors.CodeCfgInvalid, "XSQL_MAX_ROWS must be a non-negative integer", map[string]any{"value": envMaxRows})
}
return v, nil
}
if profile.MaxRows > 0 {
return profile.MaxRows, nil
}
return DefaultQueryMaxRows, nil
}

// ResolveMCPMaxRowsLimit returns the configured MCP max_rows ceiling.
func ResolveMCPMaxRowsLimit(mcpCfg config.MCPConfig) int {
if mcpCfg.MaxRowsLimit > 0 {
return mcpCfg.MaxRowsLimit
}
return DefaultMCPMaxRowsLimit
}

// ResolveMCPQueryMaxRows returns the MCP row limit (never unlimited).
// MCP max_rows 0 means unset (use defaults). Negative values are rejected.
func ResolveMCPQueryMaxRows(profile config.Profile, mcpCfg config.MCPConfig, argValue int, argSet bool) (effective int, meta MCPMaxRowsMeta, xe *errors.XError) {
if argSet {
if argValue < 0 {
return 0, meta, errors.New(errors.CodeCfgInvalid, "max_rows must be >= 0 for MCP query", map[string]any{"max_rows": argValue})
}
if argValue == 0 {
argSet = false
}
}

resolved := 0
if argSet && argValue > 0 {
resolved = argValue
meta.Requested = argValue
} else if profile.MaxRows > 0 {
resolved = profile.MaxRows
} else if mcpCfg.MaxRows > 0 {
resolved = mcpCfg.MaxRows
} else {
resolved = DefaultMCPQueryMaxRows
}

ceiling := ResolveMCPMaxRowsLimit(mcpCfg)
if resolved > ceiling {
if meta.Requested == 0 {
meta.Requested = resolved
}
meta.Clamped = true
meta.Limit = ceiling
resolved = ceiling
}
return resolved, meta, nil
}

// ResolveMCPMaxResponseBytes returns the MCP JSON response byte cap.
func ResolveMCPMaxResponseBytes(mcpCfg config.MCPConfig) int {
if mcpCfg.MaxResponseBytes > 0 {
return mcpCfg.MaxResponseBytes
}
return DefaultMCPMaxResponseBytes
}
Loading
Loading