feat(ai): 增强 MCP 远程接入与上下文诊断

This commit is contained in:
Syngnat
2026-06-11 07:29:04 +08:00
parent d3278bb4c4
commit 26fb650e04
25 changed files with 923 additions and 8 deletions

View File

@@ -0,0 +1,215 @@
package mcpserver
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"strings"
)
const (
defaultRemoteMCPPublicURL = "https://<你的域名或隧道地址>/mcp"
defaultRemoteMCPServerID = "gonavi"
defaultRemoteMCPTokenHint = "<随机token>"
)
// RemoteMCPClientConfigOptions 描述给云端 Agent 生成远程 MCP 配置的参数。
type RemoteMCPClientConfigOptions struct {
Client string
DisplayName string
URL string
Token string
ServerID string
LocalAddr string
Path string
GoNaviCommand string
StandaloneCommand string
}
// ParseRemoteMCPClientConfigOptions 解析 remote-config 模式参数。
func ParseRemoteMCPClientConfigOptions(args []string) (RemoteMCPClientConfigOptions, error) {
options := RemoteMCPClientConfigOptions{
Client: "openclaw",
URL: strings.TrimSpace(os.Getenv("GONAVI_MCP_PUBLIC_URL")),
Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")),
ServerID: defaultRemoteMCPServerID,
LocalAddr: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_ADDR")),
Path: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_PATH")),
GoNaviCommand: "GoNavi.exe",
StandaloneCommand: "gonavi-mcp-server",
}
if options.URL == "" {
options.URL = defaultRemoteMCPPublicURL
}
if options.Token == "" {
options.Token = defaultRemoteMCPTokenHint
}
if options.LocalAddr == "" {
options.LocalAddr = defaultStreamableHTTPAddr
}
if options.Path == "" {
options.Path = defaultStreamableHTTPPath
}
fs := flag.NewFlagSet("gonavi-mcp-server remote-config", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&options.Client, "client", options.Client, "remote MCP client name, for example openclaw or hermans")
fs.StringVar(&options.URL, "url", options.URL, "public Streamable HTTP MCP URL")
fs.StringVar(&options.Token, "token", options.Token, "bearer token used by the remote MCP client")
fs.StringVar(&options.ServerID, "server-id", options.ServerID, "MCP server id in generated config")
fs.StringVar(&options.LocalAddr, "addr", options.LocalAddr, "local HTTP listen address for GoNavi")
fs.StringVar(&options.Path, "path", options.Path, "local and public MCP path")
fs.StringVar(&options.GoNaviCommand, "gonavi-command", options.GoNaviCommand, "GoNavi application command on Windows")
fs.StringVar(&options.StandaloneCommand, "standalone-command", options.StandaloneCommand, "standalone gonavi-mcp-server command")
if err := fs.Parse(args); err != nil {
return RemoteMCPClientConfigOptions{}, err
}
if fs.NArg() > 0 {
return RemoteMCPClientConfigOptions{}, fmt.Errorf("未知 remote-config 参数: %s", strings.Join(fs.Args(), " "))
}
return normalizeRemoteMCPClientConfigOptions(options), nil
}
func normalizeRemoteMCPClientConfigOptions(options RemoteMCPClientConfigOptions) RemoteMCPClientConfigOptions {
options.Client = strings.ToLower(strings.TrimSpace(options.Client))
if options.Client == "" {
options.Client = "remote-agent"
}
options.DisplayName = remoteMCPClientDisplayName(options.Client, options.DisplayName)
options.URL = strings.TrimSpace(options.URL)
if options.URL == "" {
options.URL = defaultRemoteMCPPublicURL
}
options.Token = strings.TrimSpace(options.Token)
if options.Token == "" {
options.Token = defaultRemoteMCPTokenHint
}
options.ServerID = strings.TrimSpace(options.ServerID)
if options.ServerID == "" {
options.ServerID = defaultRemoteMCPServerID
}
options.LocalAddr = strings.TrimSpace(options.LocalAddr)
if options.LocalAddr == "" {
options.LocalAddr = defaultStreamableHTTPAddr
}
options.Path = strings.TrimSpace(options.Path)
if options.Path == "" {
options.Path = defaultStreamableHTTPPath
}
if !strings.HasPrefix(options.Path, "/") {
options.Path = "/" + options.Path
}
options.GoNaviCommand = strings.TrimSpace(options.GoNaviCommand)
if options.GoNaviCommand == "" {
options.GoNaviCommand = "GoNavi.exe"
}
options.StandaloneCommand = strings.TrimSpace(options.StandaloneCommand)
if options.StandaloneCommand == "" {
options.StandaloneCommand = "gonavi-mcp-server"
}
return options
}
func remoteMCPClientDisplayName(client string, fallback string) string {
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
return trimmed
}
switch strings.ToLower(strings.TrimSpace(client)) {
case "openclaw":
return "OpenClaw"
case "hermans":
return "Hermans"
default:
return "远程 Agent"
}
}
// RenderRemoteMCPClientConfig 生成给远程 Agent 和 Windows 本机分别使用的配置文本。
func RenderRemoteMCPClientConfig(options RemoteMCPClientConfigOptions) (string, error) {
normalized := normalizeRemoteMCPClientConfigOptions(options)
config := map[string]any{
"mcpServers": map[string]any{
normalized.ServerID: map[string]any{
"type": "streamable-http",
"url": normalized.URL,
"headers": map[string]string{
"Authorization": "Bearer " + normalized.Token,
},
},
},
}
configJSON, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", fmt.Errorf("生成远程 MCP 配置失败: %w", err)
}
launch := remoteMCPHTTPLaunchCommand(normalized.GoNaviCommand, true, normalized.LocalAddr, normalized.Path, normalized.Token)
standalone := remoteMCPHTTPLaunchCommand(normalized.StandaloneCommand, false, normalized.LocalAddr, normalized.Path, normalized.Token)
lines := []string{
fmt.Sprintf("GoNavi MCP 远程接入配置 - %s", normalized.DisplayName),
"",
"云端 Agent 配置(不要写数据库账号密码):",
string(configJSON),
"",
"Windows 本机启动 GoNavi MCP HTTP",
launch,
"",
"独立 MCP Server 启动方式:",
standalone,
"",
"验证顺序:",
fmt.Sprintf("1. Windows 本机访问 http://%s/healthz确认返回 ok。", normalized.LocalAddr),
fmt.Sprintf("2. %s 中配置上面的 Streamable HTTP MCPURL 指向公网/隧道后的 %s。", normalized.DisplayName, normalized.URL),
"3. 先调用 get_connections 获取 connectionId再调用 get_databases / get_tables / get_columns / get_table_ddl。",
"",
"安全边界:",
"- 数据库连接、账号和密码继续保存在 Windows GoNavi。",
"- 云端 Agent 只保存 MCP URL 和 Bearer Token。",
"- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。",
}
return strings.Join(lines, "\n") + "\n", nil
}
// WriteRemoteMCPClientConfig 把远程 MCP 配置写入指定输出,供 CLI 模式复用。
func WriteRemoteMCPClientConfig(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}
options, err := ParseRemoteMCPClientConfigOptions(args)
if err != nil {
return err
}
text, err := RenderRemoteMCPClientConfig(options)
if err != nil {
return err
}
_, err = io.WriteString(w, text)
return err
}
func remoteMCPHTTPLaunchCommand(command string, appSubcommand bool, addr string, path string, token string) string {
parts := []string{
command,
}
if appSubcommand {
parts = append(parts, "mcp-server")
}
parts = append(parts, "http", "--addr", addr, "--path", path, "--token", token)
for index, part := range parts {
parts[index] = quoteCommandPart(part)
}
return strings.Join(parts, " ")
}
func quoteCommandPart(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return `""`
}
if !strings.ContainsAny(trimmed, " \t\"") {
return trimmed
}
return `"` + strings.ReplaceAll(trimmed, `"`, `\"`) + `"`
}

View File

@@ -0,0 +1,83 @@
package mcpserver
import (
"bytes"
"strings"
"testing"
)
func TestParseRemoteMCPClientConfigOptionsUsesEnvAndFlags(t *testing.T) {
t.Setenv("GONAVI_MCP_PUBLIC_URL", "https://agent.example.com/mcp")
t.Setenv("GONAVI_MCP_HTTP_TOKEN", "env-token")
t.Setenv("GONAVI_MCP_HTTP_ADDR", "127.0.0.1:9100")
t.Setenv("GONAVI_MCP_HTTP_PATH", "/env-mcp")
options, err := ParseRemoteMCPClientConfigOptions([]string{
"--client", "hermans",
"--path", "mcp",
"--token", "flag-token",
})
if err != nil {
t.Fatalf("ParseRemoteMCPClientConfigOptions returned error: %v", err)
}
if options.DisplayName != "Hermans" {
t.Fatalf("expected Hermans display name, got %q", options.DisplayName)
}
if options.URL != "https://agent.example.com/mcp" {
t.Fatalf("expected env url, got %q", options.URL)
}
if options.Token != "flag-token" {
t.Fatalf("expected flag token, got %q", options.Token)
}
if options.Path != "/mcp" {
t.Fatalf("expected normalized path, got %q", options.Path)
}
}
func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) {
text, err := RenderRemoteMCPClientConfig(RemoteMCPClientConfigOptions{
Client: "openclaw",
URL: "https://openclaw.example.com/mcp",
Token: "secret-token",
LocalAddr: "127.0.0.1:8765",
Path: "/mcp",
GoNaviCommand: `C:\Program Files\GoNavi\GoNavi.exe`,
StandaloneCommand: "gonavi-mcp-server",
})
if err != nil {
t.Fatalf("RenderRemoteMCPClientConfig returned error: %v", err)
}
for _, want := range []string{
"GoNavi MCP 远程接入配置 - OpenClaw",
`"type": "streamable-http"`,
`"url": "https://openclaw.example.com/mcp"`,
`"Authorization": "Bearer secret-token"`,
`"C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token`,
`gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token`,
"数据库连接、账号和密码继续保存在 Windows GoNavi",
"allowMutating=true",
} {
if !strings.Contains(text, want) {
t.Fatalf("expected rendered config to contain %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "gonavi-mcp-server mcp-server http") {
t.Fatalf("standalone command must not include app-only mcp-server subcommand, got:\n%s", text)
}
}
func TestWriteRemoteMCPClientConfigWritesRenderedText(t *testing.T) {
var buffer bytes.Buffer
err := WriteRemoteMCPClientConfig(&buffer, []string{
"--client", "openclaw",
"--url", "https://example.com/mcp",
"--token", "token-1",
})
if err != nil {
t.Fatalf("WriteRemoteMCPClientConfig returned error: %v", err)
}
if !strings.Contains(buffer.String(), "https://example.com/mcp") {
t.Fatalf("expected written config to contain public url, got %s", buffer.String())
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"GoNavi-Wails/internal/ai"
@@ -15,6 +16,7 @@ import (
const (
defaultMaxRowsPerResult = 200
maxRowsPerResultLimit = 1000
redactedOpaqueTarget = "opaque-connection-string-configured"
)
type Service struct {
@@ -534,14 +536,49 @@ func describeConnectionTarget(config connection.ConnectionConfig) string {
return host
}
if uri := strings.TrimSpace(config.URI); uri != "" {
return uri
return redactConnectionTarget(uri)
}
if dsn := strings.TrimSpace(config.DSN); dsn != "" {
return dsn
return redactConnectionTarget(dsn)
}
return strings.TrimSpace(config.Database)
}
func redactConnectionTarget(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" && parsed.Host != "" {
parsed.User = nil
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
if looksLikeOpaqueConnectionString(trimmed) {
return redactedOpaqueTarget
}
return trimmed
}
func looksLikeOpaqueConnectionString(value string) bool {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
return false
}
return strings.Contains(lower, "@") ||
strings.Contains(lower, "password=") ||
strings.Contains(lower, "pwd=") ||
strings.Contains(lower, "user=") ||
strings.Contains(lower, "uid=") ||
strings.Contains(lower, "token=") ||
strings.Contains(lower, "secret=") ||
strings.Contains(lower, "access_key=") ||
strings.Contains(lower, "api_key=")
}
func decodeNamedStringSlice(data interface{}, keys ...string) ([]string, error) {
switch items := data.(type) {
case nil:

View File

@@ -134,6 +134,50 @@ func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) {
}
}
func TestGetConnectionsRedactsOpaqueURIAndDSNTargets(t *testing.T) {
backend := &fakeBackend{
savedConnections: []connection.SavedConnectionView{
{
ID: "pg-uri",
Name: "Postgres URI",
Config: connection.ConnectionConfig{
Type: "postgres",
URI: "postgres://postgres:secret@db.local:5432/app?sslmode=disable",
},
},
{
ID: "mysql-dsn",
Name: "MySQL DSN",
Config: connection.ConnectionConfig{
Type: "mysql",
DSN: "root:secret@tcp(db.local:3306)/app?charset=utf8mb4",
},
},
},
}
service := NewService(backend)
result, out, err := service.GetConnections(context.Background(), nil, emptyArgs{})
if err != nil {
t.Fatalf("GetConnections returned error: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected success result, got %#v", result)
}
if len(out.Connections) != 2 {
t.Fatalf("expected 2 connections, got %d", len(out.Connections))
}
if out.Connections[0].Target != "postgres://db.local:5432/app" {
t.Fatalf("expected URI target to remove credentials and query, got %q", out.Connections[0].Target)
}
if strings.Contains(out.Connections[0].Target, "secret") || strings.Contains(out.Connections[0].Target, "postgres@") {
t.Fatalf("URI target leaked credentials: %q", out.Connections[0].Target)
}
if out.Connections[1].Target != redactedOpaqueTarget {
t.Fatalf("expected opaque DSN target to be redacted, got %q", out.Connections[1].Target)
}
}
func TestGetAllColumnsReturnsCrossTableColumnSummaries(t *testing.T) {
backend := &fakeBackend{
editableConnection: connection.SavedConnectionView{