mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 02:49:49 +08:00
- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql - OpenClaw/Hermans 向导补充安全边界与结构模式命令 - 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
223 lines
7.9 KiB
Go
223 lines
7.9 KiB
Go
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
|
||
SchemaOnly bool
|
||
}
|
||
|
||
// 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",
|
||
SchemaOnly: parseBoolEnvDefault("GONAVI_MCP_SCHEMA_ONLY", true),
|
||
}
|
||
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")
|
||
fs.BoolVar(&options.SchemaOnly, "schema-only", options.SchemaOnly, "generate a schema-only remote MCP launch command without execute_sql")
|
||
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, normalized.SchemaOnly)
|
||
standalone := remoteMCPHTTPLaunchCommand(normalized.StandaloneCommand, false, normalized.LocalAddr, normalized.Path, normalized.Token, normalized.SchemaOnly)
|
||
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。",
|
||
"- 默认 schema-only 模式不会注册 execute_sql,适合只给 OpenClaw/Hermans 读取库表结构。",
|
||
"- 如明确去掉 --schema-only 开放 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, schemaOnly bool) string {
|
||
parts := []string{
|
||
command,
|
||
}
|
||
if appSubcommand {
|
||
parts = append(parts, "mcp-server")
|
||
}
|
||
parts = append(parts, "http", "--addr", addr, "--path", path, "--token", token)
|
||
if schemaOnly {
|
||
parts = append(parts, "--schema-only")
|
||
}
|
||
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, `"`, `\"`) + `"`
|
||
}
|