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
1 change: 1 addition & 0 deletions .agents/skills/xsql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Table and CSV formats are for humans — they lack the `ok`/`schema_version` env

```bash
# MCP server for AI assistant integration (read-only by default)
# Tools: query, schema_list_tables, schema_dump, schema_describe_table, profile_list, profile_show
xsql mcp server --attr source=codex-cli --attr agent=codex # stdio transport (default)
# Writes (only when user explicitly requests): add --unsafe-allow-write and ensure profile unsafe_allow_write: true
xsql mcp server --unsafe-allow-write --attr source=codex-cli --attr agent=codex
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Add the xsql MCP server to your Claude Desktop configuration:
}
```

Once started, Claude can query databases directly via the MCP protocol (read-only by default). To allow writes over MCP, set profile `unsafe_allow_write: true` **and** add `--unsafe-allow-write` to the MCP server `args`.
Once started, Claude can query databases directly via the MCP protocol (read-only by default). MCP also exposes schema discovery tools (`schema_list_tables`, `schema_dump`, `schema_describe_table`) with the same JSON contract as the CLI. To allow writes over MCP, set profile `unsafe_allow_write: true` **and** add `--unsafe-allow-write` to the MCP server `args`.

### Option 5: AGENTS.md / Rules (Cursor/Windsurf)

Expand Down
2 changes: 1 addition & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ xsql query "<SQL>" -p <profile> -f json --attr source=codex-cli --attr agent=cod
}
```

启动后,Claude 可以直接通过 MCP 协议查询数据库(默认只读)。若需经 MCP 写入,除 profile 设置 `unsafe_allow_write: true` 外,还须在 `args` 中加入 `--unsafe-allow-write` 启动 MCP server。
启动后,Claude 可以直接通过 MCP 协议查询数据库(默认只读)。MCP 还提供 `schema_list_tables`、`schema_dump`、`schema_describe_table` 等 schema discovery tools(与 CLI JSON 契约一致)。若需经 MCP 写入,除 profile 设置 `unsafe_allow_write: true` 外,还须在 `args` 中加入 `--unsafe-allow-write` 启动 MCP server。

### 方式五:AGENTS.md / Rules(Cursor/Windsurf)

Expand Down
7 changes: 5 additions & 2 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,15 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au
```

### MCP Tools
MCP Server 提供以下 tools:
MCP Server 提供以下 tools(均经 `internal/app`,SSH/连接行为与 CLI/Web 一致):
- **query**: 执行 SQL 查询(默认只读;写入需 profile `unsafe_allow_write: true` 且 server 启动时 `--unsafe-allow-write`)
- **schema_list_tables**: 轻量表清单(可选 `table_pattern`、`include_system`),适合大库先做 discovery
- **schema_dump**: 与 `xsql schema dump` 等价的结构化导出(建议配合 `table_pattern` 控制体积)
- **schema_describe_table**: 单表结构(列、索引、外键);PostgreSQL 需 `schema`,MySQL 可省略
- **profile_list**: 列出所有配置的 profiles
- **profile_show**: 查看 profile 详情

MCP 写入与 CLI 一样采用双重授权:仅在 profile 与 MCP server 启动参数同时开启时,`query` tool 才会绕过只读保护。
MCP 写入与 CLI 一样采用双重授权:仅在 profile 与 MCP server 启动参数同时开启时,`query` tool 才会绕过只读保护。Schema tools 始终只读。

### 集成示例
在 Claude Desktop 配置中添加:
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@
- DB driver:通过 registry 注册,实现新数据库无需改动核心逻辑。
- 输出 formatter:通过接口注册新格式。
- Frontend:CLI/MCP/TUI/Web 仅是调用 `internal/app` 的不同适配层。
- MCP:`internal/mcp` 注册 MCP tools,数据库与 schema 操作经 `internal/app`(与 Web 相同),不重复实现连接/SSH。
- Web:本地 HTTP server 与嵌入式前端通过 `internal/web` 适配,复用 `internal/app` 业务服务。
50 changes: 49 additions & 1 deletion docs/cli-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,55 @@ xsql mcp server --transport streamable_http --http-addr 127.0.0.1:8787 --http-au
}
```

**输出格式:** MCP tool 调用返回 JSON 格式,遵循 xsql 的标准输出契约(`ok`、`schema_version`、`data`/`error`)。
4. **schema_list_tables**: 轻量表清单(schema discovery 首选)
```json
{
"name": "schema_list_tables",
"inputSchema": {
"type": "object",
"required": ["profile"],
"properties": {
"profile": {"type": "string"},
"table_pattern": {"type": "string", "description": "Optional glob filter (*, ?)"},
"include_system": {"type": "boolean", "description": "Include system tables"}
}
}
}
```

5. **schema_dump**: 导出表结构(与 `xsql schema dump` JSON `data` 同形)
```json
{
"name": "schema_dump",
"inputSchema": {
"type": "object",
"required": ["profile"],
"properties": {
"profile": {"type": "string"},
"table_pattern": {"type": "string"},
"include_system": {"type": "boolean"}
}
}
}
```

6. **schema_describe_table**: 单表结构详情
```json
{
"name": "schema_describe_table",
"inputSchema": {
"type": "object",
"required": ["profile", "table"],
"properties": {
"profile": {"type": "string"},
"schema": {"type": "string", "description": "PostgreSQL schema (optional on MySQL)"},
"table": {"type": "string"}
}
}
}
```

**输出格式:** MCP tool 调用返回 JSON 格式,遵循 xsql 的标准输出契约(`ok`、`schema_version`、`data`/`error`)。MCP 数据库路径复用 `internal/app`(SSH `skip_host_key` 等与 CLI 一致)。

**安全说明:**
- query tool 默认只读模式(双重保护:SQL 静态分析 + DB 事务级只读)
Expand Down
5 changes: 3 additions & 2 deletions docs/rfcs/0003-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ Status: Implemented
auth_token: "keyring:mcp/http_token"
allow_plaintext_token: false
```
- MCP Server 提供的 tools:
- MCP Server 提供的 tools(详见 RFC 0015 增量):
- `query`: 执行 SQL 查询(支持只读模式)
- `schema_list_tables` / `schema_dump` / `schema_describe_table`: schema discovery
- `profile_list`: 列出所有 profiles
- `profile_show`: 查看 profile 详情

Expand All @@ -42,7 +43,7 @@ Status: Implemented
- Streamable HTTP: 使用 MCP Streamable HTTP 传输,必须启用鉴权
- 兼容性策略:
- 新增能力,不影响 CLI 现有功能
- MCP server 使用独立的代码路径,复用 internal 层的核心能力
- MCP server 经 `internal/app` 复用连接/SSH/schema 能力(RFC 0015)

### MCP Tools 设计
1. **query_tool**:
Expand Down
41 changes: 41 additions & 0 deletions docs/rfcs/0015-mcp-app-layer-schema-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# RFC 0015: MCP 复用 app 层与 Schema Discovery Tools

Status: Implemented

## 摘要
MCP Server 的 `query` 工具此前在 `internal/mcp` 内重复实现连接/SSH 逻辑,未复用 `internal/app`,导致 profile 的 `skip_host_key` 等行为与 CLI/Web 不一致。本 RFC 将 MCP 数据库操作统一经 `internal/app`(与 Web UI 相同),并新增轻量 schema discovery tools,使 AI 客户端无需猜测表名。

## 背景 / 动机
- **痛点**:MCP 约 150 行重复连接代码;SSH `skip_host_key` 在 MCP 路径被忽略;无 schema 工具,agent 只能手写 information_schema 查询。
- **目标**:MCP/Web/CLI 共享 `app.ResolveProfile` / `app.Query` / `app.ListTables` 等;暴露与 CLI JSON 契约一致的 schema tools;保留 RFC 0014 写入双重授权与 stats 记录。
- **非目标**:MCP query 默认行数限制(后续 PR);MCP 侧 `--ssh-skip-known-hosts-check` CLI 开关(仍仅 profile `skip_host_key` + 与 CLI 相同的 app 解析逻辑)。

## 方案(Proposed)
### MCP Tools(新增)
| Tool | 用途 | 输入 |
|------|------|------|
| `schema_list_tables` | 轻量表清单(LLM 友好) | `profile`, 可选 `table_pattern`, `include_system` |
| `schema_dump` | 与 `xsql schema dump` 等价的结构化导出 | `profile`, 可选 `table_pattern`, `include_system` |
| `schema_describe_table` | 单表列/索引/外键详情 | `profile`, `table`, 可选 `schema`(PG 必填) |

输出均遵循标准信封:`{"ok":true,"schema_version":1,"data":{...}}`。

### 技术设计
- `internal/mcp`:`query` / schema tools 调用 `internal/app`;`WriteAllowed` 不变;stats `cmd` 字段区分 `query`、`schema_list_tables`、`schema_dump`、`schema_describe_table`。
- 导出 `app.SSHOptionsForProfile`(原 `resolveSSHOptions` 的公开包装)供一致性单测。
- `query` 路径保留 `app.QueryRequest` 结构,便于后续扩展默认 `LIMIT` 等选项。

## 兼容性与迁移
- **不破坏**:现有 tool 名称与 query/profile 工具 JSON 契约不变;新增 tools 为纯增量。
- query 错误文案可能与 CLI 完全一致(如 `profile not found`),不再使用 MCP 专有 wording。

## 安全与隐私
- schema tools 为只读;不暴露 secrets。
- 写入仍仅 `query` + 双重授权(RFC 0014)。

## 测试计划
- 单元:`SSHOptionsForProfile` profile `skip_host_key`;MCP handler 参数校验。
- E2E:MCP schema tools 对 MySQL 8 / PostgreSQL 16;SSH skip_host_key 与 CLI 一致(dial 阶段错误码)。

## 未决问题
- 无
1 change: 1 addition & 0 deletions docs/rfcs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
| [0012](0012-goja-js-data-analysis.md) | goja Data Analysis & ReAct Loop | Implemented |
| [0013](0013-ai-multi-tool-calls-sequential-execution.md) | AI Multi Tool Call Sequential Execution & Dialect Guidance | Implemented |
| [0014](0014-mcp-write-dual-authorization.md) | MCP 写入双重授权 | Implemented |
| [0015](0015-mcp-app-layer-schema-tools.md) | MCP 复用 app 层与 Schema Tools | Implemented |

## 何时需要 RFC
当变更会影响以下任一项时,必须先提交/更新 RFC(并同步更新对应 `docs/*`):
Expand Down
7 changes: 7 additions & 0 deletions internal/app/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ func ResolveReconnectableSSH(ctx context.Context, profile config.Profile, allowP
return rd, nil
}

// SSHOptionsForProfile builds SSH options from a resolved profile, resolving secrets.
// skipHostKeyCheck is the runtime override (e.g. CLI --ssh-skip-known-hosts-check);
// profile ssh_proxy.skip_host_key is also honored.
func SSHOptionsForProfile(profile config.Profile, allowPlaintext, skipHostKeyCheck bool) (ssh.Options, *errors.XError) {
return resolveSSHOptions(profile, allowPlaintext, skipHostKeyCheck)
}

// resolveSSHOptions builds SSH options from a profile, resolving secrets.
func resolveSSHOptions(profile config.Profile, allowPlaintext, skipHostKeyCheck bool) (ssh.Options, *errors.XError) {
passphrase := profile.SSHConfig.Passphrase
Expand Down
19 changes: 18 additions & 1 deletion internal/app/conn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,23 @@ func TestResolveReconnectableSSH_NilCallback(t *testing.T) {
_ = xe
}

func TestSSHOptionsForProfile_ProfileSkipHostKey(t *testing.T) {
profile := config.Profile{
SSHConfig: &config.SSHProxy{
Host: "bastion.example.com",
SkipHostKey: true,
},
}

opts, xe := SSHOptionsForProfile(profile, false, false)
if xe != nil {
t.Fatalf("unexpected error: %v", xe)
}
if !opts.SkipKnownHostsCheck {
t.Error("expected SkipKnownHostsCheck from profile skip_host_key")
}
}

func TestResolveSSHOptions_Success(t *testing.T) {
profile := config.Profile{
SSHConfig: &config.SSHProxy{
Expand All @@ -422,7 +439,7 @@ func TestResolveSSHOptions_Success(t *testing.T) {
},
}

opts, xe := resolveSSHOptions(profile, true, true)
opts, xe := SSHOptionsForProfile(profile, true, true)
if xe != nil {
t.Fatalf("unexpected error: %v", xe)
}
Expand Down
56 changes: 56 additions & 0 deletions internal/mcp/app_consistency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package mcp

import (
"testing"

"github.com/zx06/xsql/internal/app"
"github.com/zx06/xsql/internal/config"
"github.com/zx06/xsql/internal/stats"
)

// MCP database tools use internal/app, which applies the same SSH option rules as CLI/Web.
func TestMCPQueryPathMatchesCLISSHOptions(t *testing.T) {
cfg := config.File{
Profiles: map[string]config.Profile{
"remote": {
DB: "mysql",
SSHProxy: "bastion",
},
},
SSHProxies: map[string]config.SSHProxy{
"bastion": {
Host: "jump.example.com",
Port: 22,
User: "jump",
SkipHostKey: true,
},
},
}

handler := NewToolHandler(&cfg, stats.StatsConfig{}, ServerOptions{})
profile, xe := handler.resolveProfile("remote")
if xe != nil {
t.Fatalf("resolveProfile: %v", xe)
}

// MCP query uses SkipHostKeyCheck=false at runtime; profile skip_host_key still applies.
mcpSSH, xe := app.SSHOptionsForProfile(profile, false, false)
if xe != nil {
t.Fatalf("SSHOptionsForProfile: %v", xe)
}

cliSSH, xe := app.SSHOptionsForProfile(profile, false, false)
if xe != nil {
t.Fatalf("SSHOptionsForProfile cli: %v", xe)
}

if mcpSSH.SkipKnownHostsCheck != cliSSH.SkipKnownHostsCheck {
t.Fatalf("skip host key mismatch: mcp=%v cli=%v", mcpSSH.SkipKnownHostsCheck, cliSSH.SkipKnownHostsCheck)
}
if mcpSSH.Host != cliSSH.Host || mcpSSH.User != cliSSH.User {
t.Fatalf("ssh target mismatch: mcp=%+v cli=%+v", mcpSSH, cliSSH)
}
if !mcpSSH.SkipKnownHostsCheck {
t.Error("expected skip_host_key from profile to enable SkipKnownHostsCheck")
}
}
41 changes: 41 additions & 0 deletions internal/mcp/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package mcp

import (
"encoding/json"

"github.com/modelcontextprotocol/go-sdk/mcp"

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

func (h *ToolHandler) successResult(data any) (*mcp.CallToolResult, any, error) {
jsonData, err := json.MarshalIndent(map[string]any{
"ok": true,
"schema_version": 1,
"data": data,
}, "", " ")
if err != nil {
return h.errorResult(errors.Wrap(errors.CodeInternal, "failed to marshal result", nil, err)), nil, nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: string(jsonData)},
},
}, nil, nil
}

func (h *ToolHandler) errorResult(xe *errors.XError) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{
&mcp.TextContent{Text: h.formatError(xe)},
},
}
}

func (h *ToolHandler) errorFromErr(err error) *mcp.CallToolResult {
if err == nil {
return h.errorResult(errors.New(errors.CodeInternal, "unknown error", nil))
}
return h.errorResult(errors.AsOrWrap(err))
}
61 changes: 61 additions & 0 deletions internal/mcp/response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package mcp

import (
"strings"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"

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

func TestSuccessResult_Envelope(t *testing.T) {
handler := NewToolHandler(&config.File{Profiles: map[string]config.Profile{}}, stats.StatsConfig{}, ServerOptions{})
result, _, err := handler.successResult(map[string]any{"n": 1})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.IsError {
t.Fatal("expected success")
}
text := result.Content[0].(*mcp.TextContent).Text
if !strings.Contains(text, `"ok": true`) || !strings.Contains(text, `"schema_version": 1`) {
t.Fatalf("unexpected payload: %s", text)
}
}

func TestSuccessResult_MarshalFailure(t *testing.T) {
handler := NewToolHandler(&config.File{Profiles: map[string]config.Profile{}}, stats.StatsConfig{}, ServerOptions{})
ch := make(chan int)
result, _, err := handler.successResult(ch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || !result.IsError {
t.Fatal("expected marshal failure to become error result")
}
if !strings.Contains(result.Content[0].(*mcp.TextContent).Text, "INTERNAL") {
t.Fatalf("expected INTERNAL, got %s", result.Content[0].(*mcp.TextContent).Text)
}
}

func TestErrorFromErr_Nil(t *testing.T) {
handler := NewToolHandler(&config.File{Profiles: map[string]config.Profile{}}, stats.StatsConfig{}, ServerOptions{})
result := handler.errorFromErr(nil)
if result == nil || !result.IsError {
t.Fatal("expected error result")
}
}

func TestErrorResult_WrapsXError(t *testing.T) {
handler := NewToolHandler(&config.File{Profiles: map[string]config.Profile{}}, stats.StatsConfig{}, ServerOptions{})
result := handler.errorResult(errors.New(errors.CodeCfgInvalid, "bad config", nil))
if result == nil || !result.IsError {
t.Fatal("expected error result")
}
if !strings.Contains(result.Content[0].(*mcp.TextContent).Text, "CFG_INVALID") {
t.Fatalf("unexpected: %s", result.Content[0].(*mcp.TextContent).Text)
}
}
Loading
Loading