feat(ai): 完善远程 MCP 结构模式与面板稳定性

- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql

- OpenClaw/Hermans 向导补充安全边界与结构模式命令

- 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
This commit is contained in:
Syngnat
2026-06-11 09:26:54 +08:00
parent 4a944ad23f
commit 450d1d66b4
20 changed files with 293 additions and 119 deletions

View File

@@ -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)
}

View File

@@ -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{

View File

@@ -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 == "" {

View File

@@ -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) {

View File

@@ -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
}

View 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)
}
}