diff --git a/cmd/gonavi-mcp-server/README.md b/cmd/gonavi-mcp-server/README.md index 3f65b7d..48ba95a 100644 --- a/cmd/gonavi-mcp-server/README.md +++ b/cmd/gonavi-mcp-server/README.md @@ -20,6 +20,8 @@ - 如果 SQL 包含 DDL/DML,必须显式传 `allowMutating=true` - `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200` +远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。 + ## 运行方式 开发态直接运行: @@ -44,13 +46,13 @@ go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server ```powershell $env:GONAVI_MCP_HTTP_TOKEN = "<随机token>" -go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp +go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --schema-only ``` 安装包主程序也支持同样模式: ```powershell -& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token "<随机token>" +& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token "<随机token>" --schema-only ``` 默认建议只监听 `127.0.0.1`,再通过 SSH 隧道、反向代理或内网网关暴露给云端 Agent。不要在没有 TLS、防火墙和鉴权的情况下直接监听公网地址。 @@ -58,13 +60,13 @@ go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp 无图形界面或需要把配置交给云端 Agent 时,可直接生成 OpenClaw / Hermans 等远程 MCP 配置: ```powershell -& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server remote-config --client openclaw --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" +& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server remote-config --client openclaw --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only ``` 独立 server 开发态也支持同样能力: ```powershell -go run ./cmd/gonavi-mcp-server remote-config --client hermans --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" +go run ./cmd/gonavi-mcp-server remote-config --client hermans --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" --schema-only ``` ## Claude Code / Codex / OpenClaw / Hermans @@ -116,7 +118,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接 推荐接入形态: 1. Windows 本机运行 GoNavi,并保持能访问已保存的数据库连接。 -2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>`。 +2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`。 3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。 4. 在 OpenClaw / Hermans 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`。 5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_tables`、`get_columns`、`get_table_ddl` 等工具读取结构。 @@ -137,7 +139,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接 } ``` -不要把数据库 `host/user/password` 写入云端 Agent 的配置文件。`execute_sql` 写操作仍受 GoNavi AI 安全设置控制,且必须显式传 `allowMutating=true`。 +不要把数据库 `host/user/password` 写入云端 Agent 的配置文件。默认 `--schema-only` 不暴露 `execute_sql`;如果你明确需要远程执行 SQL,可以去掉该参数,此时 `execute_sql` 仍受 GoNavi AI 安全设置控制,写操作必须显式传 `allowMutating=true`。 ## MCP 客户端配置示例 diff --git a/cmd/gonavi-mcp-server/main.go b/cmd/gonavi-mcp-server/main.go index 9a5c54b..95e7bfd 100644 --- a/cmd/gonavi-mcp-server/main.go +++ b/cmd/gonavi-mcp-server/main.go @@ -32,7 +32,7 @@ func run(ctx context.Context, args []string) error { if err != nil { return err } - log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s", options.Addr, options.Path) + log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v", options.Addr, options.Path, options.SchemaOnly) return mcpserver.RunAppStreamableHTTPServer(ctx, options) case "remote-config", "--remote-config": return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:]) diff --git a/frontend/src/App.ai-panel-error-boundary.test.ts b/frontend/src/App.ai-panel-error-boundary.test.ts index 42f4b28..ada430e 100644 --- a/frontend/src/App.ai-panel-error-boundary.test.ts +++ b/frontend/src/App.ai-panel-error-boundary.test.ts @@ -6,11 +6,16 @@ const appSource = readFileSync( fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)), 'utf8', ); +const aiPanelBoundarySource = readFileSync( + fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)), + 'utf8', +); describe('AI panel lazy-load guard', () => { it('keeps AI panel failures scoped to the panel area with retry support', () => { expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';"); - expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component'); + expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';"); + expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component'); expect(appSource).toContain(' { const caseToken = `case '${action}':`; @@ -250,8 +254,10 @@ describe('global appearance tokens', () => { expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)'); expect(appSource).toContain('ListInstalledFontFamilies()'); expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState(EMPTY_INSTALLED_FONT_FAMILIES);'); - expect(appSource).toContain('data-gonavi-linux-cjk-font-banner="true"'); - expect(appSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失'); + expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';"); + expect(appSource).toContain(' React.ReactNode; - onError?: (error: Error, errorInfo: React.ErrorInfo) => void; -} - -interface AIPanelErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -class AIPanelErrorBoundary extends React.Component< - AIPanelErrorBoundaryProps, - AIPanelErrorBoundaryState -> { - constructor(props: AIPanelErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - this.props.onError?.(error, errorInfo); - } - - render() { - if (this.state.hasError) { - return this.props.fallback(this.state.error); - } - - return this.props.children; - } -} - function App() { const [isModalOpen, setIsModalOpen] = useState(false); const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false); @@ -3416,51 +3381,15 @@ function App() { {showLinuxCJKFontBanner && ( -
- -
-
- Linux CJK fonts missing / Ubuntu 中文字体缺失 -
-
- Chinese text may render as □□□. Install fonts, then restart GoNavi: - - {linuxCJKFontInstallHint} - -
-
- - -
+ }} + onDismiss={() => setIsLinuxCJKFontBannerDismissed(true)} + /> )} diff --git a/frontend/src/components/LinuxCJKFontBanner.tsx b/frontend/src/components/LinuxCJKFontBanner.tsx new file mode 100644 index 0000000..cc8f750 --- /dev/null +++ b/frontend/src/components/LinuxCJKFontBanner.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Button } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; + +interface LinuxCJKFontBannerProps { + darkMode: boolean; + installHint: string; + onOpenFontSettings: () => void; + onDismiss: () => void; +} + +const LinuxCJKFontBanner: React.FC = ({ + darkMode, + installHint, + onOpenFontSettings, + onDismiss, +}) => ( +
+ +
+
+ Linux CJK fonts missing / Ubuntu 中文字体缺失 +
+
+ Chinese text may render as □□□. Install fonts, then restart GoNavi: + + {installHint} + +
+
+ + +
+); + +export default LinuxCJKFontBanner; diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx index 41bf539..2abf4cf 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx @@ -191,7 +191,8 @@ describe('AIMCPClientInstallPanel', () => { expect(markup).toContain('远程桥接'); expect(markup).toContain('当前已选中,将复制远程接入说明'); expect(markup).toContain('远程接入边界'); - expect(markup).toContain('云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL'); + expect(markup).toContain('云端 Agent 默认通过 schema-only MCP 工具读取连接摘要、库表和 DDL'); + expect(markup).toContain('不注册 execute_sql'); expect(markup).toContain('OpenClaw 远程 MCP 快速配置'); expect(markup).toContain('公网/隧道 URL'); expect(markup).toContain('云端 Agent 能访问到的 Streamable HTTP MCP 地址'); @@ -206,13 +207,14 @@ describe('AIMCPClientInstallPanel', () => { expect(markup).toContain('"type": "streamable-http"'); expect(markup).toContain('"url": "https://<你的域名或隧道地址>/mcp"'); expect(markup).toContain('"Authorization": "Bearer <随机token>"'); - expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>'); + expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only'); expect(markup).toContain('Windows 启动 GoNavi MCP HTTP'); - expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); - expect(markup).toContain('独立二进制:gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); + expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only'); + expect(markup).toContain('独立二进制:gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only'); expect(markup).toContain('验证顺序'); expect(markup).toContain('安全边界'); expect(markup).toContain('数据库账号和密码仍保存在 Windows GoNavi'); + expect(markup).toContain('默认 --schema-only 不注册 execute_sql'); expect(markup).toContain('CLI 检测:远程 Agent 不需要检测本机 openclaw 命令'); expect(markup).toContain('复制 OpenClaw 远程接入说明'); }); diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx index 8026c00..5a9c330 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx @@ -292,7 +292,7 @@ const AIMCPClientInstallPanel: React.FC = ({ lineHeight: 1.7, }} > - 远程接入边界:数据库连接信息和密码仍保存在 Windows GoNavi;云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL。跨机器接入请使用 GoNavi Streamable HTTP 模式,并配合 token、隧道或反向代理。 + 远程接入边界:数据库连接信息和密码仍保存在 Windows GoNavi;云端 Agent 默认通过 schema-only MCP 工具读取连接摘要、库表和 DDL,不注册 execute_sql。跨机器接入请使用 GoNavi Streamable HTTP 模式,并配合 token、隧道或反向代理。 )} {remoteQuickStart && ( @@ -311,7 +311,7 @@ const AIMCPClientInstallPanel: React.FC = ({ {remoteQuickStart.displayName} 远程 MCP 快速配置
- 下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。 + 下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码;默认 schema-only 只暴露结构工具。
{REMOTE_MCP_PARAMETER_GUIDES.map((item) => ( diff --git a/frontend/src/components/ai/AIPanelErrorBoundary.tsx b/frontend/src/components/ai/AIPanelErrorBoundary.tsx new file mode 100644 index 0000000..edc0ad1 --- /dev/null +++ b/frontend/src/components/ai/AIPanelErrorBoundary.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +interface AIPanelErrorBoundaryProps { + children: React.ReactNode; + fallback: (error: Error | null) => React.ReactNode; + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +interface AIPanelErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class AIPanelErrorBoundary extends React.Component< + AIPanelErrorBoundaryProps, + AIPanelErrorBoundaryState +> { + constructor(props: AIPanelErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + this.props.onError?.(error, errorInfo); + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + + return this.props.children; + } +} + +export default AIPanelErrorBoundary; diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts index f82297c..333395e 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts @@ -78,6 +78,7 @@ describe('mcpClientInstallPanelState', () => { expect(getMCPClientStatusTone(status, false).label).toBe('远程桥接'); expect(getMCPClientInstallStateLabel(status)).toBe('外部工具接入状态:需配置远程 MCP 桥接'); + expect(getMCPClientOptionSummary(status)).toContain('默认 schema-only'); expect(getMCPClientOptionSummary(status)).toContain('不复制数据库密码'); expect(getMCPClientDetectionSummary(status)).toContain('本机无需检测 openclaw 命令'); expect(getSelectedMCPClientStateLine(status)).toContain('数据库密码仍留在 GoNavi 本机'); diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.ts b/frontend/src/components/ai/mcpClientInstallPanelState.ts index f08262f..cc9ebe3 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.ts @@ -101,7 +101,7 @@ export const getMCPClientOptionSummary = (status: AIMCPClientInstallStatus | und return '接入状态读取异常,建议先刷新再处理。'; } if (isRemoteMCPClientStatus(status)) { - return '适合云端 Agent:通过远程 MCP 桥接读取 GoNavi 表结构,不复制数据库密码。'; + return '适合云端 Agent:默认 schema-only 读取 GoNavi 表结构,不复制数据库密码,不暴露 SQL 执行。'; } return '尚未把当前 GoNavi MCP 接入到这里。'; }; diff --git a/frontend/src/utils/mcpClientInstallStatus.test.ts b/frontend/src/utils/mcpClientInstallStatus.test.ts index 78f17e8..4bdd51f 100644 --- a/frontend/src/utils/mcpClientInstallStatus.test.ts +++ b/frontend/src/utils/mcpClientInstallStatus.test.ts @@ -133,12 +133,13 @@ describe('mcpClientInstallStatus helpers', () => { const guide = buildRemoteMCPClientGuide(openClaw); expect(guide).toContain('GoNavi MCP 远程接入说明 - OpenClaw'); expect(guide).toContain('云端 Agent 不需要保存数据库密码'); + expect(guide).toContain('默认使用 schema-only 模式,不注册 execute_sql'); expect(guide).toContain('不能直接使用 Windows 本地 stdio 命令'); expect(guide).toContain('allowMutating=true'); expect(guide).toContain('"type": "streamable-http"'); expect(guide).toContain('"Authorization": "Bearer <随机token>"'); - expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>'); - expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); + expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only'); + expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only'); }); it('builds remote quick-start snippets for cloud agents without database secrets', () => { @@ -152,10 +153,11 @@ describe('mcpClientInstallStatus helpers', () => { expect(quickStart.configJson).toContain('"url": "https://<你的域名或隧道地址>/mcp"'); expect(quickStart.configJson).toContain('"Authorization": "Bearer <随机token>"'); expect(quickStart.configJson).not.toContain('password'); - expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token>'); - expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); - expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); + expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only'); + expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only'); + expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only'); expect(quickStart.verificationSteps.join('\n')).toContain('get_connections'); + expect(quickStart.securityNotes.join('\n')).toContain('默认 --schema-only 不注册 execute_sql'); expect(quickStart.securityNotes.join('\n')).toContain('allowMutating=true'); }); }); diff --git a/frontend/src/utils/mcpClientInstallStatus.ts b/frontend/src/utils/mcpClientInstallStatus.ts index 13349ca..7fa13a6 100644 --- a/frontend/src/utils/mcpClientInstallStatus.ts +++ b/frontend/src/utils/mcpClientInstallStatus.ts @@ -232,7 +232,7 @@ export const buildRemoteMCPClientGuide = ( '目标:', '- 数据库连接、账号和密码继续保存在 Windows 上的 GoNavi。云端 Agent 不需要保存数据库密码。', '- 云端 Agent 只通过 MCP tools 读取 get_connections/get_databases/get_tables/get_columns/get_table_ddl 等结果。', - '- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。', + '- 远程接入默认使用 schema-only 模式,不注册 execute_sql,适合只给 OpenClaw/Hermans 读取库表结构。', '', '当前边界:', '- GoNavi 内置 MCP 本机入口是 stdio,适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。', @@ -253,6 +253,7 @@ export const buildRemoteMCPClientGuide = ( 'CLI / 服务启动命令:', quickStart.launchCommand, `或设置环境变量:GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`, + '如果明确需要远程执行 SQL,可去掉 --schema-only;此时 execute_sql 仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。', '', status?.message ? `当前提示:${status.message}` : '', ].filter((line, index, lines) => line || index < lines.length - 1).join('\n'); @@ -263,9 +264,9 @@ export const buildRemoteMCPClientQuickStart = ( ): RemoteMCPClientQuickStart => { const displayName = String(status?.displayName || '远程 Agent').trim(); const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw'; - const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`; - const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`; - const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token>`; + const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`; + const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`; + const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token> --schema-only`; const configJson = JSON.stringify({ mcpServers: { gonavi: { @@ -291,8 +292,9 @@ export const buildRemoteMCPClientQuickStart = ( ], securityNotes: [ '数据库账号和密码仍保存在 Windows GoNavi,本段配置不要写数据库密码。', + '默认 --schema-only 不注册 execute_sql,远程 Agent 只能走库表结构类工具。', 'HTTP MCP 必须使用随机 Bearer Token,并放在 HTTPS、私有网络或受控隧道后面。', - 'execute_sql 仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。', + '如去掉 --schema-only 开放 execute_sql,仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。', ], }; }; diff --git a/internal/mcpserver/remote_config.go b/internal/mcpserver/remote_config.go index 952319e..5c97b12 100644 --- a/internal/mcpserver/remote_config.go +++ b/internal/mcpserver/remote_config.go @@ -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) } diff --git a/internal/mcpserver/remote_config_test.go b/internal/mcpserver/remote_config_test.go index 8ac60f7..a5acdc7 100644 --- a/internal/mcpserver/remote_config_test.go +++ b/internal/mcpserver/remote_config_test.go @@ -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{ diff --git a/internal/mcpserver/run.go b/internal/mcpserver/run.go index 7346612..189e12b 100644 --- a/internal/mcpserver/run.go +++ b/internal/mcpserver/run.go @@ -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 == "" { diff --git a/internal/mcpserver/run_test.go b/internal/mcpserver/run_test.go index 753c22d..c85b5b1 100644 --- a/internal/mcpserver/run_test.go +++ b/internal/mcpserver/run_test.go @@ -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) { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 449a279..feb552a 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -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 } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..d85da06 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 9fb3204..ec702f6 100644 --- a/main.go +++ b/main.go @@ -99,7 +99,7 @@ func runMCPServerMode(ctx context.Context, args []string) error { if err != nil { return err } - logger.Infof("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s", options.Addr, options.Path) + logger.Infof("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s schemaOnly=%v", options.Addr, options.Path, options.SchemaOnly) return mcpserver.RunAppStreamableHTTPServer(ctx, options) case "remote-config", "--remote-config": return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:])