mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
🐛 fix(mcp): 修复执行SQL结果不可见
- execute_sql 文本 Content 输出可读的 SQL 执行摘要和 Markdown 结果表\n- 保留 structuredContent 结构化结果,兼容机器解析客户端\n- 新增 in-memory MCP 调用回归测试,覆盖 select 1 可见结果
This commit is contained in:
@@ -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] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user