🐛 fix(mcp): 修复执行SQL结果不可见

- execute_sql 文本 Content 输出可读的 SQL 执行摘要和 Markdown 结果表\n- 保留 structuredContent 结构化结果,兼容机器解析客户端\n- 新增 in-memory MCP 调用回归测试,覆盖 select 1 可见结果
This commit is contained in:
Syngnat
2026-06-29 12:32:07 +08:00
parent ea4f88a20d
commit 3a471bc1aa
2 changed files with 228 additions and 2 deletions

View File

@@ -2,8 +2,13 @@ package mcpserver
import (
"context"
"encoding/json"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
appcore "GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/connection"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -23,6 +28,63 @@ func TestNewServerIncludesExecuteSQLByDefault(t *testing.T) {
assertToolPresent(t, toolNames, "execute_sql")
}
func TestExecuteSQLCallToolReturnsSelectResultContent(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{
ID: "mysql-main",
Config: connection.ConnectionConfig{
Type: "mysql",
Database: "app",
},
},
inspection: appcore.SQLInspection{
StatementCount: 1,
ReadOnly: true,
Statements: []appcore.SQLStatementInspection{
{Index: 1, Keyword: "select", ReadOnly: true},
},
},
safetyLevel: ai.PermissionReadOnly,
queryResult: connection.QueryResult{
Success: true,
QueryID: "query-select-1",
Data: []connection.ResultSetData{
{
StatementIndex: 1,
Columns: []string{"1"},
Rows: []map[string]interface{}{{"1": 1}},
},
},
},
}
result := callServerTool(t, NewServer(backend), "execute_sql", map[string]any{
"connectionId": "mysql-main",
"sql": "select 1",
})
if result.IsError {
t.Fatalf("expected execute_sql success, got error content: %#v", result.Content)
}
text := firstTextContent(result)
if strings.TrimSpace(text) == "" {
t.Fatalf("expected execute_sql to return text content for MCP clients, got %#v", result.Content)
}
if !strings.Contains(text, "SQL 执行成功") || !strings.Contains(text, "结果集 1") {
t.Fatalf("expected readable SQL result summary, got %s", text)
}
if !strings.Contains(text, "| 1 |") || !strings.Contains(text, "| --- |") {
t.Fatalf("expected select result table in MCP text content, got %s", text)
}
structured, err := json.Marshal(result.StructuredContent)
if err != nil {
t.Fatalf("failed to marshal structuredContent: %v", err)
}
if !strings.Contains(string(structured), `"results"`) || !strings.Contains(string(structured), `"rows"`) {
t.Fatalf("expected structuredContent to retain SQL result JSON, got %s", string(structured))
}
}
func listServerToolNames(t *testing.T, server *mcp.Server) map[string]bool {
t.Helper()
@@ -53,6 +115,38 @@ func listServerToolNames(t *testing.T, server *mcp.Server) map[string]bool {
return names
}
func callServerTool(t *testing.T, server *mcp.Server, name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
ctx := context.Background()
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(ctx, serverTransport, nil)
if err != nil {
t.Fatalf("server.Connect returned error: %v", err)
}
defer serverSession.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("client.Connect returned error: %v", err)
}
defer clientSession.Close()
payload, err := json.Marshal(args)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: name,
Arguments: json.RawMessage(payload),
})
if err != nil {
t.Fatalf("CallTool returned error: %v", err)
}
return result
}
func assertToolPresent(t *testing.T, names map[string]bool, name string) {
t.Helper()
if !names[name] {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"GoNavi-Wails/internal/ai"
@@ -555,7 +556,7 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args
}
normalizedResults, truncated := normalizeResultSets(resultSets, normalizeMaxRowsPerResult(args.MaxRowsPerResult))
return successResult(), executeSQLResult{
output := executeSQLResult{
ConnectionID: view.ID,
DBName: dbName,
StatementCount: inspection.StatementCount,
@@ -565,13 +566,22 @@ func (s *Service) ExecuteSQL(ctx context.Context, req *mcp.CallToolRequest, args
Truncated: truncated,
Statements: toStatementSummaries(inspection.Statements),
Results: normalizedResults,
}, nil
}
return textResult(formatExecuteSQLResultContent(output)), output, nil
}
func successResult() *mcp.CallToolResult {
return &mcp.CallToolResult{}
}
func textResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: text},
},
}
}
func toolError(format string, args ...interface{}) *mcp.CallToolResult {
return &mcp.CallToolResult{
IsError: true,
@@ -922,6 +932,128 @@ func normalizeResultSets(resultSets []connection.ResultSetData, maxRows int) ([]
return normalized, truncatedAny
}
func formatExecuteSQLResultContent(result executeSQLResult) string {
var builder strings.Builder
builder.WriteString("SQL 执行成功")
if result.QueryID != "" {
builder.WriteString("queryId=")
builder.WriteString(result.QueryID)
}
builder.WriteString("\n")
builder.WriteString(fmt.Sprintf("语句数:%d结果集%d", result.StatementCount, len(result.Results)))
if result.Truncated {
builder.WriteString(",结果已截断")
}
if result.Message != "" {
builder.WriteString("\n消息")
builder.WriteString(result.Message)
}
if len(result.Results) == 0 {
builder.WriteString("\n无结果集返回。")
return builder.String()
}
for index, resultSet := range result.Results {
builder.WriteString("\n\n")
builder.WriteString(fmt.Sprintf("结果集 %d", index+1))
if resultSet.StatementIndex > 0 {
builder.WriteString(fmt.Sprintf("(语句 #%d", resultSet.StatementIndex))
}
builder.WriteString(fmt.Sprintf("%d 行", resultSet.RowCount))
if resultSet.Truncated {
builder.WriteString(fmt.Sprintf(",仅显示前 %d 行", len(resultSet.Rows)))
}
if len(resultSet.Messages) > 0 {
builder.WriteString("\n消息")
builder.WriteString(strings.Join(resultSet.Messages, "\n"))
}
if len(resultSet.Columns) == 0 {
if len(resultSet.Rows) == 0 {
builder.WriteString("\n无列/行数据。")
continue
}
resultSet.Columns = inferColumnsFromRows(resultSet.Rows)
}
if len(resultSet.Columns) == 0 {
continue
}
builder.WriteString("\n")
builder.WriteString(formatMarkdownTable(resultSet.Columns, resultSet.Rows))
}
return builder.String()
}
func inferColumnsFromRows(rows []map[string]interface{}) []string {
seen := make(map[string]struct{})
columns := []string{}
for _, row := range rows {
keys := make([]string, 0, len(row))
for key := range row {
if _, ok := seen[key]; ok {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
seen[key] = struct{}{}
columns = append(columns, key)
}
}
return columns
}
func formatMarkdownTable(columns []string, rows []map[string]interface{}) string {
var builder strings.Builder
builder.WriteString("|")
for _, column := range columns {
builder.WriteString(" ")
builder.WriteString(escapeMarkdownTableCell(column))
builder.WriteString(" |")
}
builder.WriteString("\n|")
for range columns {
builder.WriteString(" --- |")
}
for _, row := range rows {
builder.WriteString("\n|")
for _, column := range columns {
builder.WriteString(" ")
builder.WriteString(escapeMarkdownTableCell(formatSQLValue(row[column])))
builder.WriteString(" |")
}
}
return builder.String()
}
func formatSQLValue(value interface{}) string {
if value == nil {
return "NULL"
}
switch typed := value.(type) {
case string:
return typed
case []byte:
return string(typed)
case fmt.Stringer:
return typed.String()
}
data, err := json.Marshal(value)
if err == nil {
return string(data)
}
return fmt.Sprint(value)
}
func escapeMarkdownTableCell(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "|", "\\|")
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
value = strings.ReplaceAll(value, "\n", "<br>")
return value
}
func toStatementSummaries(items []appcore.SQLStatementInspection) []sqlStatementSummary {
result := make([]sqlStatementSummary, 0, len(items))
for _, item := range items {