mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 22:12:29 +08:00
✨ feat(ai): 增强 MCP 远程接入与上下文诊断
This commit is contained in:
215
internal/mcpserver/remote_config.go
Normal file
215
internal/mcpserver/remote_config.go
Normal 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 MCP,URL 指向公网/隧道后的 %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, `"`, `\"`) + `"`
|
||||
}
|
||||
83
internal/mcpserver/remote_config_test.go
Normal file
83
internal/mcpserver/remote_config_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user