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

@@ -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 Servertransport 选择 Streamable HTTPURL 指向 `/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 客户端配置示例

View File

@@ -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:])

View File

@@ -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('<AIPanelErrorBoundary');
expect(appSource).toContain('key={aiPanelRenderNonce}');
expect(appSource).toContain('AI 面板加载失败');

View File

@@ -6,6 +6,10 @@ const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const linuxCJKFontBannerSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
'utf8',
);
const getGlobalShortcutCaseBlock = (action: string) => {
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<InstalledFontFamily[]>(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('<LinuxCJKFontBanner');
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
expect(linuxCJKFontBannerSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
expect(appSource).toContain('matchFontFamilyOption');
expect(appSource).toContain('showSearch');

View File

@@ -10,9 +10,11 @@ import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
import LogPanel from './components/LogPanel';
import AISettingsModal from './components/AISettingsModal';
import AIChatPanel from './components/AIChatPanel';
import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
@@ -201,43 +203,6 @@ const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogStat
confirmLoading: false,
});
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;
}
}
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
@@ -3416,51 +3381,15 @@ function App() {
</div>
{showLinuxCJKFontBanner && (
<div
data-gonavi-linux-cjk-font-banner="true"
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderBottom: darkMode ? '1px solid rgba(250,204,21,0.20)' : '1px solid rgba(217,119,6,0.18)',
background: darkMode ? 'rgba(250,204,21,0.10)' : 'rgba(255,247,237,0.92)',
color: darkMode ? 'rgba(254,249,195,0.96)' : '#7c2d12',
fontSize: 12,
lineHeight: 1.55,
}}
>
<InfoCircleOutlined style={{ flexShrink: 0, color: darkMode ? '#facc15' : '#d97706' }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 700 }}>
Linux CJK fonts missing / Ubuntu
</div>
<div>
Chinese text may render as . Install fonts, then restart GoNavi:
<code style={{ marginLeft: 6, fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all' }}>
{linuxCJKFontInstallHint}
</code>
</div>
</div>
<Button
size="small"
onClick={() => {
<LinuxCJKFontBanner
darkMode={darkMode}
installHint={linuxCJKFontInstallHint || ''}
onOpenFontSettings={() => {
setThemeModalSection('appearance');
setIsThemeModalOpen(true);
}}
>
Font Settings
</Button>
<Button
size="small"
type="text"
onClick={() => setIsLinuxCJKFontBannerDismissed(true)}
style={{ color: 'inherit' }}
>
Close
</Button>
</div>
}}
onDismiss={() => setIsLinuxCJKFontBannerDismissed(true)}
/>
)}
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>

View File

@@ -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<LinuxCJKFontBannerProps> = ({
darkMode,
installHint,
onOpenFontSettings,
onDismiss,
}) => (
<div
data-gonavi-linux-cjk-font-banner="true"
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderBottom: darkMode ? '1px solid rgba(250,204,21,0.20)' : '1px solid rgba(217,119,6,0.18)',
background: darkMode ? 'rgba(250,204,21,0.10)' : 'rgba(255,247,237,0.92)',
color: darkMode ? 'rgba(254,249,195,0.96)' : '#7c2d12',
fontSize: 12,
lineHeight: 1.55,
}}
>
<InfoCircleOutlined style={{ flexShrink: 0, color: darkMode ? '#facc15' : '#d97706' }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 700 }}>
Linux CJK fonts missing / Ubuntu
</div>
<div>
Chinese text may render as . Install fonts, then restart GoNavi:
<code style={{ marginLeft: 6, fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all' }}>
{installHint}
</code>
</div>
</div>
<Button
size="small"
onClick={onOpenFontSettings}
>
Font Settings
</Button>
<Button
size="small"
type="text"
onClick={onDismiss}
style={{ color: 'inherit' }}
>
Close
</Button>
</div>
);
export default LinuxCJKFontBanner;

View File

@@ -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('&quot;type&quot;: &quot;streamable-http&quot;');
expect(markup).toContain('&quot;url&quot;: &quot;https://&lt;你的域名或隧道地址&gt;/mcp&quot;');
expect(markup).toContain('&quot;Authorization&quot;: &quot;Bearer &lt;随机token&gt;&quot;');
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://&lt;你的域名或隧道地址&gt;/mcp --token &lt;随机token&gt;');
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://&lt;你的域名或隧道地址&gt;/mcp --token &lt;随机token&gt; --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 &lt;随机token&gt;');
expect(markup).toContain('独立二进制gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt;');
expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt; --schema-only');
expect(markup).toContain('独立二进制gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt; --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 远程接入说明');
});

View File

@@ -292,7 +292,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
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
</div>
)}
{remoteQuickStart && (
@@ -311,7 +311,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
{remoteQuickStart.displayName} MCP
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
Agent GUI/CLI Windows GoNavi 使 MCP URL Bearer Token
Agent GUI/CLI Windows GoNavi 使 MCP URL Bearer Token schema-only
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 10 }}>
{REMOTE_MCP_PARAMETER_GUIDES.map((item) => (

View File

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

View File

@@ -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 本机');

View File

@@ -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 接入到这里。';
};

View File

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

View File

@@ -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。',
],
};
};

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

View File

@@ -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:])