diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 4c9da97b..dfefed90 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -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] { diff --git a/internal/mcpserver/service.go b/internal/mcpserver/service.go index d92ae3a4..b46fd34a 100644 --- a/internal/mcpserver/service.go +++ b/internal/mcpserver/service.go @@ -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", "
") + return value +} + func toStatementSummaries(items []appcore.SQLStatementInspection) []sqlStatementSummary { result := make([]sqlStatementSummary, 0, len(items)) for _, item := range items {