mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-10 23:11:40 +08:00
✨ feat(ai): 完善远程 MCP 结构模式与面板稳定性
- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql - OpenClaw/Hermans 向导补充安全边界与结构模式命令 - 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
This commit is contained in:
@@ -26,6 +26,7 @@ type RemoteMCPClientConfigOptions struct {
|
||||
Path string
|
||||
GoNaviCommand string
|
||||
StandaloneCommand string
|
||||
SchemaOnly bool
|
||||
}
|
||||
|
||||
// ParseRemoteMCPClientConfigOptions 解析 remote-config 模式参数。
|
||||
@@ -39,6 +40,7 @@ func ParseRemoteMCPClientConfigOptions(args []string) (RemoteMCPClientConfigOpti
|
||||
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
|
||||
@@ -63,6 +65,7 @@ func ParseRemoteMCPClientConfigOptions(args []string) (RemoteMCPClientConfigOpti
|
||||
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
|
||||
}
|
||||
@@ -145,8 +148,8 @@ func RenderRemoteMCPClientConfig(options RemoteMCPClientConfigOptions) (string,
|
||||
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)
|
||||
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),
|
||||
"",
|
||||
@@ -167,7 +170,8 @@ func RenderRemoteMCPClientConfig(options RemoteMCPClientConfigOptions) (string,
|
||||
"安全边界:",
|
||||
"- 数据库连接、账号和密码继续保存在 Windows GoNavi。",
|
||||
"- 云端 Agent 只保存 MCP URL 和 Bearer Token。",
|
||||
"- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。",
|
||||
"- 默认 schema-only 模式不会注册 execute_sql,适合只给 OpenClaw/Hermans 读取库表结构。",
|
||||
"- 如明确去掉 --schema-only 开放 execute_sql,它仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。",
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
}
|
||||
@@ -189,7 +193,7 @@ func WriteRemoteMCPClientConfig(w io.Writer, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteMCPHTTPLaunchCommand(command string, appSubcommand bool, addr string, path string, token string) string {
|
||||
func remoteMCPHTTPLaunchCommand(command string, appSubcommand bool, addr string, path string, token string, schemaOnly bool) string {
|
||||
parts := []string{
|
||||
command,
|
||||
}
|
||||
@@ -197,6 +201,9 @@ func remoteMCPHTTPLaunchCommand(command string, appSubcommand bool, addr string,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestParseRemoteMCPClientConfigOptionsUsesEnvAndFlags(t *testing.T) {
|
||||
if options.Path != "/mcp" {
|
||||
t.Fatalf("expected normalized path, got %q", options.Path)
|
||||
}
|
||||
if !options.SchemaOnly {
|
||||
t.Fatal("expected remote config to default to schema-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) {
|
||||
@@ -43,6 +46,7 @@ func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) {
|
||||
Path: "/mcp",
|
||||
GoNaviCommand: `C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
StandaloneCommand: "gonavi-mcp-server",
|
||||
SchemaOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRemoteMCPClientConfig returned error: %v", err)
|
||||
@@ -53,9 +57,10 @@ func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) {
|
||||
`"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`,
|
||||
`"C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token --schema-only`,
|
||||
`gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token --schema-only`,
|
||||
"数据库连接、账号和密码继续保存在 Windows GoNavi",
|
||||
"默认 schema-only 模式不会注册 execute_sql",
|
||||
"allowMutating=true",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
@@ -67,6 +72,21 @@ func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRemoteMCPClientConfigCanExposeSQLWhenExplicitlyRequested(t *testing.T) {
|
||||
text, err := RenderRemoteMCPClientConfig(RemoteMCPClientConfigOptions{
|
||||
Client: "openclaw",
|
||||
URL: "https://openclaw.example.com/mcp",
|
||||
Token: "secret-token",
|
||||
SchemaOnly: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRemoteMCPClientConfig returned error: %v", err)
|
||||
}
|
||||
if strings.Contains(text, "http --addr 127.0.0.1:8765 --path /mcp --token secret-token --schema-only") {
|
||||
t.Fatalf("expected launch commands to omit schema-only when disabled, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRemoteMCPClientConfigWritesRenderedText(t *testing.T) {
|
||||
var buffer bytes.Buffer
|
||||
err := WriteRemoteMCPClientConfig(&buffer, []string{
|
||||
|
||||
@@ -26,6 +26,7 @@ type HTTPServerOptions struct {
|
||||
Path string
|
||||
Token string
|
||||
JSONResponse bool
|
||||
SchemaOnly bool
|
||||
}
|
||||
|
||||
// RunAppStdioServer 启动基于真实 GoNavi App 的 stdio MCP server。
|
||||
@@ -73,7 +74,7 @@ func RunStreamableHTTPServer(ctx context.Context, backend Backend, options HTTPS
|
||||
return err
|
||||
}
|
||||
|
||||
server := NewServer(backend)
|
||||
server := NewServerWithOptions(backend, ServerOptions{SchemaOnly: normalized.SchemaOnly})
|
||||
streamableHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return server
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
@@ -131,6 +132,7 @@ func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) {
|
||||
Path: defaultPath,
|
||||
Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")),
|
||||
JSONResponse: true,
|
||||
SchemaOnly: parseBoolEnvDefault("GONAVI_MCP_SCHEMA_ONLY", false),
|
||||
}
|
||||
fs := flag.NewFlagSet("gonavi-mcp-server http", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
@@ -138,6 +140,7 @@ func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) {
|
||||
fs.StringVar(&options.Path, "path", options.Path, "HTTP MCP path")
|
||||
fs.StringVar(&options.Token, "token", options.Token, "bearer token required by remote MCP clients")
|
||||
fs.BoolVar(&options.JSONResponse, "json-response", options.JSONResponse, "return application/json streamable responses when possible")
|
||||
fs.BoolVar(&options.SchemaOnly, "schema-only", options.SchemaOnly, "only expose schema inspection tools and omit execute_sql")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return HTTPServerOptions{}, err
|
||||
}
|
||||
@@ -147,6 +150,17 @@ func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) {
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func parseBoolEnvDefault(name string, fallback bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, error) {
|
||||
options.Addr = strings.TrimSpace(options.Addr)
|
||||
if options.Addr == "" {
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) {
|
||||
"--addr", "0.0.0.0:8765",
|
||||
"--path", "mcp",
|
||||
"--token", "flag-token",
|
||||
"--schema-only",
|
||||
"--json-response=false",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -38,6 +39,9 @@ func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) {
|
||||
if normalized.JSONResponse {
|
||||
t.Fatal("expected json response flag to be false")
|
||||
}
|
||||
if !normalized.SchemaOnly {
|
||||
t.Fatal("expected schema-only flag to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHTTPServerOptionsRequiresBearerToken(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,17 @@ import (
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// ServerOptions 控制 MCP server 对外暴露的工具范围。
|
||||
type ServerOptions struct {
|
||||
// SchemaOnly 仅暴露连接、库表、字段、索引、外键、触发器和 DDL 工具,不注册 execute_sql。
|
||||
SchemaOnly bool
|
||||
}
|
||||
|
||||
func NewServer(backend Backend) *mcp.Server {
|
||||
return NewServerWithOptions(backend, ServerOptions{})
|
||||
}
|
||||
|
||||
func NewServerWithOptions(backend Backend, options ServerOptions) *mcp.Server {
|
||||
server := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "gonavi-ai",
|
||||
Version: implementationVersion(),
|
||||
@@ -60,10 +70,12 @@ func NewServer(backend Backend) *mcp.Server {
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取建表或建视图语句。",
|
||||
}, service.GetTableDDL)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "execute_sql",
|
||||
Description: "执行 SQL,支持多语句结果集。执行范围受 GoNavi AI 设置中的安全控制约束;命中允许范围内的 DML/DDL 等非只读语句时,仍必须显式传 allowMutating=true。",
|
||||
}, service.ExecuteSQL)
|
||||
if !options.SchemaOnly {
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "execute_sql",
|
||||
Description: "执行 SQL,支持多语句结果集。执行范围受 GoNavi AI 设置中的安全控制约束;命中允许范围内的 DML/DDL 等非只读语句时,仍必须显式传 allowMutating=true。",
|
||||
}, service.ExecuteSQL)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
66
internal/mcpserver/server_test.go
Normal file
66
internal/mcpserver/server_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestNewServerWithOptionsOmitsExecuteSQLInSchemaOnlyMode(t *testing.T) {
|
||||
toolNames := listServerToolNames(t, NewServerWithOptions(&fakeBackend{}, ServerOptions{SchemaOnly: true}))
|
||||
|
||||
assertToolPresent(t, toolNames, "get_connections")
|
||||
assertToolPresent(t, toolNames, "get_table_ddl")
|
||||
assertToolAbsent(t, toolNames, "execute_sql")
|
||||
}
|
||||
|
||||
func TestNewServerIncludesExecuteSQLByDefault(t *testing.T) {
|
||||
toolNames := listServerToolNames(t, NewServer(&fakeBackend{}))
|
||||
|
||||
assertToolPresent(t, toolNames, "execute_sql")
|
||||
}
|
||||
|
||||
func listServerToolNames(t *testing.T, server *mcp.Server) map[string]bool {
|
||||
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()
|
||||
|
||||
result, err := clientSession.ListTools(ctx, &mcp.ListToolsParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools returned error: %v", err)
|
||||
}
|
||||
|
||||
names := make(map[string]bool, len(result.Tools))
|
||||
for _, tool := range result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func assertToolPresent(t *testing.T, names map[string]bool, name string) {
|
||||
t.Helper()
|
||||
if !names[name] {
|
||||
t.Fatalf("expected tool %q to be registered; got %#v", name, names)
|
||||
}
|
||||
}
|
||||
|
||||
func assertToolAbsent(t *testing.T, names map[string]bool, name string) {
|
||||
t.Helper()
|
||||
if names[name] {
|
||||
t.Fatalf("expected tool %q to be omitted; got %#v", name, names)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user