feat(mcp): 支持远程 Agent 接入和 HTTP 模式

This commit is contained in:
Syngnat
2026-06-10 21:28:00 +08:00
parent 856a5158e4
commit 0394667680
24 changed files with 640 additions and 51 deletions

View File

@@ -1,6 +1,6 @@
# GoNavi MCP Server
`gonavi-mcp-server` 会把 GoNavi 已保存连接背后的数据库能力通过 MCP `stdio` 暴露给外部客户端。
`gonavi-mcp-server` 会把 GoNavi 已保存连接背后的数据库能力通过 MCP 暴露给外部客户端。本机客户端默认使用 `stdio`;云端 Agent 可使用显式开启的 Streamable HTTP 模式
## 当前提供的 tools
@@ -28,13 +28,34 @@
go run ./cmd/gonavi-mcp-server
```
显式运行本机 `stdio`
```powershell
go run ./cmd/gonavi-mcp-server stdio
```
也可以先编译:
```powershell
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
```
## Claude Code / Codex
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token
```powershell
$env:GONAVI_MCP_HTTP_TOKEN = "<随机token>"
go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp
```
安装包主程序也支持同样模式:
```powershell
& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token "<随机token>"
```
默认建议只监听 `127.0.0.1`,再通过 SSH 隧道、反向代理或内网网关暴露给云端 Agent。不要在没有 TLS、防火墙和鉴权的情况下直接监听公网地址。
## Claude Code / Codex / OpenClaw / Hermans
正式安装包场景,推荐直接在 GoNavi 里使用“AI 设置 -> MCP 服务 -> 安装到 Claude Code / 安装到 Codex”。
@@ -78,6 +99,18 @@ tools\claude-gonavi-mcp.cmd -p "必须调用 gonavi MCP 的 get_connections 工
这个脚本会先构建 `bin\gonavi-mcp-server.exe`,再通过 `--mcp-config``--strict-mcp-config` 把 GoNavi MCP 单独注入当前 Claude 会话,避免默认混合 MCP 加载时序导致的首轮工具未挂载问题。
OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent不能直接使用 Windows 本机的 `stdio` 命令。GoNavi 的连接信息和数据库密码仍应留在 Windows 本机,由 GoNavi MCP 读取保存连接和系统凭据;远端 Agent 只拿到 MCP tools 和 `connectionId`
推荐接入形态:
1. Windows 本机运行 GoNavi并保持能访问已保存的数据库连接。
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>`
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` 等工具读取结构。
不要把数据库 `host/user/password` 写入云端 Agent 的配置文件。`execute_sql` 写操作仍受 GoNavi AI 安全设置控制,且必须显式传 `allowMutating=true`
## MCP 客户端配置示例
开发态:

View File

@@ -2,14 +2,39 @@ package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"GoNavi-Wails/internal/mcpserver"
)
func main() {
ctx := context.Background()
if err := mcpserver.RunAppStdioServer(ctx); err != nil {
err := run(ctx, os.Args[1:])
if err != nil {
log.Printf("GoNavi MCP Server 退出: %v", err)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
return mcpserver.RunAppStdioServer(ctx)
}
mode := strings.ToLower(strings.TrimSpace(args[0]))
switch mode {
case "stdio", "--stdio":
return mcpserver.RunAppStdioServer(ctx)
case "http", "--http", "streamable-http", "--streamable-http":
options, err := mcpserver.ParseHTTPServerOptions(args[1:])
if err != nil {
return err
}
log.Printf("GoNavi MCP Streamable HTTP Server 启动addr=%s path=%s", options.Addr, options.Path)
return mcpserver.RunAppStreamableHTTPServer(ctx, options)
default:
return fmt.Errorf("未知 MCP server 模式: %s支持 stdio/http", args[0])
}
}

View File

@@ -1,6 +1,6 @@
:root {
--gn-font-sans: "Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif;
--gn-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--gn-font-sans: "Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif;
--gn-font-mono: "JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
}
html, body, #root {

View File

@@ -62,14 +62,14 @@ describe('AIMCPClientInstallPanel', () => {
/>,
);
expect(markup).toContain('这里是在把 GoNavi MCP 接入 Claude Code / Codex');
expect(markup).toContain('这里是在把 GoNavi MCP 接入 Claude Code / Codex / OpenClaw / Hermans');
expect(markup).toContain('给外部工具调用');
expect(markup).toContain('这里的“安装”只会写入外部 CLI 的用户级 MCP 配置');
expect(markup).toContain('OpenClaw、Hermans 这类云端 Agent 会提供远程接入说明');
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('选择外部客户端(二选一)');
expect(markup).toContain('选择外部客户端');
expect(markup).toContain('选择目标客户端');
expect(markup).toContain('写入接入配置');
expect(markup).toContain('重启目标客户端');
expect(markup).toContain('写入或复制配置');
expect(markup).toContain('重启或配置目标端');
expect(markup).toContain('未接入');
expect(markup).toContain('需更新');
expect(markup).toContain('外部工具接入状态:已存在旧配置,需更新');
@@ -137,6 +137,65 @@ describe('AIMCPClientInstallPanel', () => {
expect(markup).toContain('已接入');
});
it('renders remote Agent clients as bridge guidance instead of local installs', () => {
const markup = renderToStaticMarkup(
<AIMCPClientInstallPanel
statuses={[
{
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
{
client: 'hermans',
displayName: 'Hermans',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'hermans',
message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
]}
selectedClient="openclaw"
selectedStatus={{
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
}}
selectedCommandText=""
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
loading={false}
statusLoading={false}
onSelectClient={() => {}}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
onInstall={() => {}}
/>,
);
expect(markup).toContain('远程桥接');
expect(markup).toContain('当前已选中,将复制远程接入说明');
expect(markup).toContain('远程接入边界');
expect(markup).toContain('云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL');
expect(markup).toContain('CLI 检测:远程 Agent 不需要检测本机 openclaw 命令');
expect(markup).toContain('复制 OpenClaw 远程接入说明');
});
it('makes repeated install avoidance explicit when the selected client already matches current GoNavi', () => {
const markup = renderToStaticMarkup(
<AIMCPClientInstallPanel

View File

@@ -3,7 +3,11 @@ import { Button } from 'antd';
import { CheckCircleFilled, CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import type { MCPClientKey } from '../../utils/mcpClientInstallStatus';
import {
isMCPClientKey,
isRemoteMCPClientStatus,
type MCPClientKey,
} from '../../utils/mcpClientInstallStatus';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import {
getMCPClientDetectionSummary,
@@ -50,7 +54,10 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
onCopyConfigPath,
onCopyLaunchCommand,
onInstall,
}) => (
}) => {
const selectedIsRemoteClient = isRemoteMCPClientStatus(selectedStatus);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
@@ -75,17 +82,17 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}}
>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
GoNavi MCP Claude Code / Codex GoNavi
GoNavi MCP Claude Code / Codex / OpenClaw / Hermans GoNavi
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
CLI MCP GoNavi MCP GoNavi GoNavi
Claude Code Codex MCP OpenClawHermans Agent
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
Claude Code Codex 1 GoNavi
1 CLI Agent MCP /访 GoNavi
</div>
</div>
<div
@@ -96,9 +103,9 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}}
>
{[
{ step: '1', title: '选择目标客户端', detail: 'Claude Code 和 Codex 二选一即可。' },
{ step: '2', title: '写入接入配置', detail: '只改用户级 MCP 配置,不会重装 GoNavi。' },
{ step: '3', title: '重启目标客户端', detail: '重启后就能在外部 CLI 里调用当前 GoNavi MCP。' },
{ step: '1', title: '选择目标客户端', detail: '本机 Claude/Codex 可自动安装OpenClaw/Hermans 走远程接入说明。' },
{ step: '2', title: '写入或复制配置', detail: '自动安装只改用户级 MCP 配置;远程 Agent 复制桥接说明。' },
{ step: '3', title: '重启或配置目标端', detail: '本机 CLI 重启后验证;云端 Agent 配置远程 MCP 地址后验证。' },
].map((item) => (
<div
key={item.step}
@@ -138,14 +145,15 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div
role="radiogroup"
aria-label="选择要安装 GoNavi MCP 的外部客户端"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}
>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
const client = isMCPClientKey(status.client) ? status.client : 'claude-code';
const remoteClient = isRemoteMCPClientStatus(status);
const active = selectedClient === client;
const tone = getMCPClientStatusTone(status, darkMode);
return (
@@ -216,7 +224,9 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
{getMCPClientInstallStateLabel(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{active ? '当前已选中,将只对这个客户端执行写入或更新。' : '点击后切换到这个客户端。'}
{active
? (remoteClient ? '当前已选中,将复制远程接入说明。' : '当前已选中,将只对这个客户端执行写入或更新。')
: (remoteClient ? '点击后查看远程接入方式。' : '点击后切换到这个客户端。')}
</div>
</button>
);
@@ -265,10 +275,27 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{getSelectedMCPClientStateLine(selectedStatus)}
</div>
{selectedIsRemoteClient && (
<div
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${darkMode ? 'rgba(56,189,248,0.22)' : 'rgba(14,165,233,0.18)'}`,
background: darkMode ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.7,
}}
>
Windows GoNavi Agent MCP DDL使 GoNavi Streamable HTTP token
</div>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
CLI {selectedStatus?.clientDetected
? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}`
: `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`}
CLI {selectedIsRemoteClient
? `远程 Agent 不需要检测本机 ${resolveMCPClientCommandName(selectedStatus)} 命令`
: selectedStatus?.clientDetected
? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}`
: `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`}
</div>
{selectedStatus?.clientPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
@@ -322,8 +349,12 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getMCPClientDetectionSummary(selectedStatus)}
{' '}
GoNavi
{!selectedIsRemoteClient && (
<>
{' '}
GoNavi
</>
)}
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
@@ -337,6 +368,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
</div>
</div>
</div>
);
);
};
export default AIMCPClientInstallPanel;

View File

@@ -67,6 +67,7 @@ export const buildMCPSetupSnapshot = (params: {
.map((status) => ({
client: status.client,
displayName: status.displayName,
installMode: status.installMode || 'auto',
installed: status.installed,
matchesCurrent: status.matchesCurrent,
clientDetected: status.clientDetected === true,

View File

@@ -112,8 +112,8 @@ export const buildAISetupHealthSnapshot = (params: {
mcpSnapshot.warnings.forEach((warning) => appendUnique(warnings, warning));
mcpSnapshot.nextActions.forEach((action) => appendUnique(nextActions, action));
if (mcpSnapshot.currentClientCount === 0) {
appendUnique(warnings, 'Claude Code / Codex 还没有任何客户端接入当前 GoNavi MCP');
appendUnique(nextActions, '如需让外部 CLI 使用 GoNavi MCP先把当前 GoNavi 接入 Claude CodeCodex');
appendUnique(warnings, 'Claude Code / Codex 还没有本机客户端接入当前 GoNavi MCPOpenClaw/Hermans 需要远程桥接');
appendUnique(nextActions, '如需让外部 Agent 使用 GoNavi MCP本机客户端可接入 Claude Code/Codex,云端 Agent 先配置远程 MCP 桥接');
}
if (mcpSnapshot.enabledServerCount > 0 && runtimeSnapshot.mcpToolCount === 0) {
appendUnique(warnings, '已启用 MCP 服务,但当前还没有发现可用 MCP 工具');

View File

@@ -48,7 +48,7 @@ export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:', category: 'review', keywords: ['schema', '表结构', '设计'] },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:', category: 'review', keywords: ['index', '索引', '慢查询'] },
{ cmd: '/health', label: '🩺 AI 配置体检', desc: '调用体检探针总览当前 AI 配置', prompt: '请先调用 inspect_ai_setup_health对当前 GoNavi AI 配置做一次完整体检,然后总结 blockers、warnings 和 nextActions。', category: 'diagnose', featured: true, keywords: ['health', '体检', 'ai配置', '探针'] },
{ cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', '外部客户端'] },
{ cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 本机客户端和 OpenClaw / Hermans 远程 Agent 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', 'openclaw', 'hermans', '外部客户端'] },
{ cmd: '/mcpadd', label: '🧭 新增 MCP 指引', desc: '查看 command、args、env 和模板怎么填', prompt: '请先调用 inspect_mcp_authoring_guide如果我贴了完整启动命令或草稿再调用 inspect_mcp_draft 试算字段和校验问题;最后结合 inspect_mcp_setup告诉我新增 GoNavi MCP 服务时 command、args、env、timeout 应该怎么填,以及最接近的模板应该选哪个。', category: 'diagnose', featured: true, keywords: ['mcp新增', 'command', 'args', 'env', '模板'] },
{ cmd: '/mcpdraft', label: '🧪 MCP 草稿校验', desc: '校验一条 MCP 启动命令怎么拆', prompt: '请先调用 inspect_mcp_draft 校验我提供的 MCP fullCommand 或 command/args/env/timeout 草稿,返回自动拆分结果、启动预览、错误/告警和 nextActions如果还缺字段说明再补充调用 inspect_mcp_authoring_guide。', category: 'diagnose', keywords: ['mcp草稿', 'mcp校验', 'fullcommand', '启动命令', '参数拆分', 'command', 'args', 'env'] },
{ cmd: '/mcptool', label: '🧩 MCP 工具参数', desc: '查看 MCP 工具 schema 和 arguments 写法', prompt: '请先调用 inspect_mcp_setup 找到当前已发现的 MCP 工具 alias如果我已经给了工具名或关键词再调用 inspect_mcp_tool_schema 读取对应 inputSchema告诉我必填参数、字段类型、枚举值、嵌套路径以及 arguments JSON 应该怎么写。', category: 'diagnose', keywords: ['mcp工具', 'mcp工具参数', 'schema', 'arguments', '参数', '工具调用', 'inputschema'] },

View File

@@ -66,4 +66,21 @@ describe('mcpClientInstallPanelState', () => {
expect(getMCPClientDetectionSummary(status)).toContain('CLI 还没加入 PATH');
expect(resolveMCPClientInstallActionLabel(status)).toBe('安装到 Claude Code外部工具');
});
it('treats OpenClaw as a remote bridge target instead of a local install', () => {
const status = buildStatus({
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
});
expect(getMCPClientStatusTone(status, false).label).toBe('远程桥接');
expect(getMCPClientInstallStateLabel(status)).toBe('外部工具接入状态:需配置远程 MCP 桥接');
expect(getMCPClientOptionSummary(status)).toContain('不复制数据库密码');
expect(getMCPClientDetectionSummary(status)).toContain('本机无需检测 openclaw 命令');
expect(getSelectedMCPClientStateLine(status)).toContain('数据库密码仍留在 GoNavi 本机');
expect(resolveMCPClientInstallActionLabel(status)).toBe('复制 OpenClaw 远程接入说明');
});
});

View File

@@ -1,4 +1,5 @@
import type { AIMCPClientInstallStatus } from '../../types';
import { isRemoteMCPClientStatus } from '../../utils/mcpClientInstallStatus';
export interface MCPClientInstallStatusTone {
label: string;
@@ -27,6 +28,13 @@ export const getMCPClientStatusTone = (
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (isRemoteMCPClientStatus(status)) {
return {
label: '远程桥接',
color: '#0284c7',
bg: darkMode ? 'rgba(56,189,248,0.16)' : 'rgba(14,165,233,0.10)',
};
}
if (hasMCPClientStatusIssue(status)) {
return {
label: '状态异常',
@@ -51,6 +59,9 @@ export const getMCPClientInstallStateLabel = (status: AIMCPClientInstallStatus |
if (hasMCPClientStatusIssue(status)) {
return '外部工具接入状态:读取失败';
}
if (isRemoteMCPClientStatus(status)) {
return '外部工具接入状态:需配置远程 MCP 桥接';
}
return '外部工具接入状态:未接入';
};
@@ -73,6 +84,9 @@ export const getMCPClientStatusSummary = (status: AIMCPClientInstallStatus | und
if (hasMCPClientStatusIssue(status)) {
return `${label} 的接入状态读取失败,建议先刷新检测。`;
}
if (isRemoteMCPClientStatus(status)) {
return `${label} 通常运行在云端或远端机器,需要通过远程 MCP 桥接调用当前 GoNavi。`;
}
return `当前还没有把这份 GoNavi MCP 接入 ${label}`;
};
@@ -86,12 +100,18 @@ export const getMCPClientOptionSummary = (status: AIMCPClientInstallStatus | und
if (hasMCPClientStatusIssue(status)) {
return '接入状态读取异常,建议先刷新再处理。';
}
if (isRemoteMCPClientStatus(status)) {
return '适合云端 Agent通过远程 MCP 桥接读取 GoNavi 表结构,不复制数据库密码。';
}
return '尚未把当前 GoNavi MCP 接入到这里。';
};
export const getMCPClientDetectionSummary = (status: AIMCPClientInstallStatus | undefined): string => {
const label = status?.displayName || '这个客户端';
const commandName = resolveMCPClientCommandName(status);
if (isRemoteMCPClientStatus(status)) {
return `${label} 通常不在这台 Windows 上运行,本机无需检测 ${commandName} 命令;请在云端配置远程 MCP 桥接地址。`;
}
if (status?.clientDetected) {
return `已检测到本机 ${commandName} 命令,接入或更新后重启 ${label} 即可验证。`;
}
@@ -108,6 +128,9 @@ export const getSelectedMCPClientStateLine = (status: AIMCPClientInstallStatus |
if (hasMCPClientStatusIssue(status)) {
return '状态读取异常,建议先刷新检测';
}
if (isRemoteMCPClientStatus(status)) {
return '需要配置远程 MCP 桥接,数据库密码仍留在 GoNavi 本机';
}
return '当前还没有接入 GoNavi MCP';
};
@@ -119,5 +142,8 @@ export const resolveMCPClientInstallActionLabel = (status: AIMCPClientInstallSta
if (status?.installed) {
return `更新 ${label} 接入配置`;
}
if (isRemoteMCPClientStatus(status)) {
return `复制 ${label} 远程接入说明`;
}
return `安装到 ${label}(外部工具)`;
};

View File

@@ -2,8 +2,10 @@ import { useCallback, useMemo, useState } from 'react';
import type { AIMCPClientInstallStatus } from '../../types';
import {
buildRemoteMCPClientGuide,
EMPTY_MCP_CLIENT_STATUSES,
formatMCPLaunchCommand,
isRemoteMCPClientStatus,
normalizeMCPClientStatuses,
pickPreferredMCPClient,
type MCPClientKey,
@@ -104,8 +106,24 @@ export const useAIMCPClientInstaller = ({
}, [messageApi, resolveAIService, syncMCPClientStatuses]);
const handleInstallSelectedMCPClient = useCallback(async () => {
const remoteClient = isRemoteMCPClientStatus(selectedMCPClientStatus);
const targetClient = selectedMCPClientStatus?.client === 'codex' ? 'codex' : 'claude-code';
const targetLabel = selectedMCPClientStatus?.displayName || (targetClient === 'codex' ? 'Codex' : 'Claude Code');
if (remoteClient) {
try {
onBeforeInstall?.();
setMCPClientSelectionTouched(true);
await copyTextToClipboard(
buildRemoteMCPClientGuide(selectedMCPClientStatus),
`${targetLabel} 远程接入说明已复制`,
);
} catch (error: any) {
void messageApi.error(error?.message || `复制 ${targetLabel} 远程接入说明失败`);
} finally {
onAfterInstall?.();
}
return;
}
if (selectedMCPClientStatus?.matchesCurrent) {
void messageApi.success(`${targetLabel} 已接入当前 GoNavi MCP无需重复写入`);
return;
@@ -134,7 +152,7 @@ export const useAIMCPClientInstaller = ({
} finally {
onAfterInstall?.();
}
}, [loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]);
}, [copyTextToClipboard, loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]);
const handleCopySelectedMCPConfigPath = useCallback(async () => {
const configPath = String(selectedMCPClientStatus?.configPath || '').trim();

View File

@@ -596,6 +596,7 @@ export interface AIMCPToolCallResult {
export interface AIMCPClientInstallStatus {
client: string;
displayName: string;
installMode?: 'auto' | 'remote';
installed: boolean;
matchesCurrent: boolean;
clientDetected?: boolean;

View File

@@ -116,14 +116,14 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
icon: "🪛",
desc: "查看当前 MCP 配置与外部接入状态",
detail:
"返回当前本地配置了哪些 MCP 服务、哪些已启用、每个服务声明了什么启动命令,以及 Claude Code / Codex 这类外部客户端写入状态与命令检测结果。适合用户问“我现在配了哪些 MCP”“为什么外部客户端还用不了”“MCP 到底写没写进去”时先读真实状态。",
"返回当前本地配置了哪些 MCP 服务、哪些已启用、每个服务声明了什么启动命令,以及 Claude Code / Codex 本机客户端写入状态、OpenClaw / Hermans 远程 Agent 接入边界与命令检测结果。适合用户问“我现在配了哪些 MCP”“为什么外部客户端还用不了”“MCP 到底写没写进去”时先读真实状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_mcp_setup",
description:
"读取当前本地 MCP 配置快照,包括 MCP 服务列表、启用状态、启动命令、环境变量 key、已发现工具以及外部客户端的 GoNavi MCP 写入状态本机 CLI 检测结果。适用于用户提到 MCP 服务配置、Claude/Codex 是否已接入、为什么外部客户端用不了、当前到底启用了哪些 MCP 时,先读取真实配置再回答。",
"读取当前本地 MCP 配置快照,包括 MCP 服务列表、启用状态、启动命令、环境变量 key、已发现工具以及外部客户端的 GoNavi MCP 写入状态本机 CLI 检测结果和远程 Agent 接入边界。适用于用户提到 MCP 服务配置、Claude/Codex/OpenClaw/Hermans 是否已接入、为什么外部客户端用不了、当前到底启用了哪些 MCP 时,先读取真实配置再回答。",
parameters: { type: "object", properties: {} },
},
},

View File

@@ -1,7 +1,7 @@
export const DEFAULT_UI_FONT_FAMILY =
'"Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif';
'"Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif';
export const DEFAULT_MONO_FONT_FAMILY =
'"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace';
'"JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace';
const MAX_FONT_FAMILY_LENGTH = 512;
@@ -17,9 +17,9 @@ export type InstalledFontFamily = {
};
const UI_FONT_FALLBACK_STACK =
'-apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", sans-serif';
'-apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", "Ubuntu", sans-serif';
const MONO_FONT_FALLBACK_STACK =
'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
'ui-monospace, "SF Mono", Menlo, Consolas, "Noto Sans Mono CJK SC", "Noto Sans Mono", "DejaVu Sans Mono", monospace';
const MONO_FONT_PRIORITY_HINTS = [
'mono',
@@ -83,10 +83,12 @@ const MAC_MONO_FONTS: FontFamilyOption[] = [
];
const LINUX_UI_FONTS: FontFamilyOption[] = [
{ value: '"Noto Sans", "Noto Sans CJK SC", sans-serif', label: 'Noto Sans', keywords: ['linux', 'default'] },
{ value: '"Ubuntu", "Noto Sans", sans-serif', label: 'Ubuntu', keywords: ['linux', 'ubuntu'] },
{ value: '"DejaVu Sans", "Noto Sans", sans-serif', label: 'DejaVu Sans', keywords: ['linux'] },
{ value: '"Liberation Sans", "Noto Sans", sans-serif', label: 'Liberation Sans', keywords: ['linux'] },
{ value: '"Noto Sans", "Noto Sans CJK SC", "Noto Sans SC", sans-serif', label: 'Noto Sans', keywords: ['linux', 'default'] },
{ value: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif', label: 'Noto Sans CJK SC', keywords: ['linux', 'cjk', '中文'] },
{ value: '"Source Han Sans SC", "Noto Sans CJK SC", sans-serif', label: 'Source Han Sans SC', keywords: ['linux', 'cjk', '思源黑体'] },
{ value: '"Ubuntu", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'Ubuntu', keywords: ['linux', 'ubuntu'] },
{ value: '"DejaVu Sans", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'DejaVu Sans', keywords: ['linux'] },
{ value: '"Liberation Sans", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'Liberation Sans', keywords: ['linux'] },
{ value: '"WenQuanYi Micro Hei", "Noto Sans CJK SC", sans-serif', label: 'WenQuanYi Micro Hei', keywords: ['linux', '文泉驿'] },
];
@@ -103,6 +105,7 @@ const SHARED_UI_FONTS: FontFamilyOption[] = [
{ value: '"PingFang SC", sans-serif', label: 'PingFang SC', keywords: ['shared', '苹方'] },
{ value: '"Microsoft YaHei", sans-serif', label: 'Microsoft YaHei', keywords: ['shared', '雅黑'] },
{ value: '"Noto Sans CJK SC", sans-serif', label: 'Noto Sans CJK SC', keywords: ['shared', 'noto'] },
{ value: '"Source Han Sans SC", sans-serif', label: 'Source Han Sans SC', keywords: ['shared', 'source han', '思源黑体'] },
];
const SHARED_MONO_FONTS: FontFamilyOption[] = [

View File

@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { AIMCPClientInstallStatus } from '../types';
import {
buildRemoteMCPClientGuide,
EMPTY_MCP_CLIENT_STATUSES,
formatMCPLaunchCommand,
isRemoteMCPClientStatus,
normalizeMCPClientStatuses,
pickPreferredMCPClient,
} from './mcpClientInstallStatus';
@@ -25,6 +27,7 @@ describe('mcpClientInstallStatus helpers', () => {
{
client: 'codex',
displayName: 'Codex',
installMode: 'auto',
installed: true,
matchesCurrent: true,
clientDetected: false,
@@ -33,6 +36,8 @@ describe('mcpClientInstallStatus helpers', () => {
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
args: [],
},
EMPTY_MCP_CLIENT_STATUSES[2],
EMPTY_MCP_CLIENT_STATUSES[3],
]);
});
@@ -110,6 +115,7 @@ describe('mcpClientInstallStatus helpers', () => {
it('keeps the user-selected client when it is still present in the latest status list', () => {
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'codex')).toBe('codex');
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'openclaw')).toBe('openclaw');
});
it('formats quoted launch commands for display and clipboard use', () => {
@@ -118,4 +124,15 @@ describe('mcpClientInstallStatus helpers', () => {
args: ['mcp-server', '--stdio'],
})).toBe('"C:/Program Files/GoNavi/GoNavi.exe" mcp-server --stdio');
});
it('marks OpenClaw and Hermans as remote bridge clients and builds a safe guide', () => {
const openClaw = EMPTY_MCP_CLIENT_STATUSES.find((item) => item.client === 'openclaw');
expect(isRemoteMCPClientStatus(openClaw)).toBe(true);
const guide = buildRemoteMCPClientGuide(openClaw);
expect(guide).toContain('GoNavi MCP 远程接入说明 - OpenClaw');
expect(guide).toContain('云端 Agent 不需要保存数据库密码');
expect(guide).toContain('不能直接使用 Windows 本地 stdio 命令');
expect(guide).toContain('allowMutating=true');
});
});

View File

@@ -1,11 +1,15 @@
import type { AIMCPClientInstallStatus } from '../types';
export type MCPClientKey = 'claude-code' | 'codex';
export type MCPClientKey = 'claude-code' | 'codex' | 'openclaw' | 'hermans';
const AUTO_MCP_CLIENTS = new Set<MCPClientKey>(['claude-code', 'codex']);
const REMOTE_MCP_CLIENTS = new Set<MCPClientKey>(['openclaw', 'hermans']);
export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installMode: 'auto',
installed: false,
matchesCurrent: false,
clientDetected: false,
@@ -15,15 +19,36 @@ export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'codex',
displayName: 'Codex',
installMode: 'auto',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'codex',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
{
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
{
client: 'hermans',
displayName: 'Hermans',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'hermans',
message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
];
const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex'];
const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex', 'openclaw', 'hermans'];
const quoteMCPCommandPart = (value: string): string => {
const text = String(value || '').trim();
@@ -33,8 +58,18 @@ const quoteMCPCommandPart = (value: string): string => {
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
const isActionableClient = (client: string): client is MCPClientKey =>
client === 'claude-code' || client === 'codex';
export const isMCPClientKey = (client: string): client is MCPClientKey =>
client === 'claude-code' || client === 'codex' || client === 'openclaw' || client === 'hermans';
export const isRemoteMCPClientStatus = (status?: Pick<AIMCPClientInstallStatus, 'client' | 'installMode'> | null): boolean => {
const client = String(status?.client || '').trim();
return status?.installMode === 'remote' || (isMCPClientKey(client) && REMOTE_MCP_CLIENTS.has(client));
};
export const supportsAutoMCPClientInstall = (status?: Pick<AIMCPClientInstallStatus, 'client' | 'installMode'> | null): boolean => {
const client = String(status?.client || '').trim();
return status?.installMode === 'auto' || (isMCPClientKey(client) && AUTO_MCP_CLIENTS.has(client));
};
const hasStatusError = (status: AIMCPClientInstallStatus): boolean =>
/|||/u.test(String(status.message || ''));
@@ -74,6 +109,7 @@ export const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]):
...base,
...item,
displayName: item.displayName || base.displayName,
installMode: item.installMode || base.installMode || 'auto',
clientDetected: item.clientDetected ?? base.clientDetected ?? false,
clientCommand: item.clientCommand || base.clientCommand,
clientPath: item.clientPath || '',
@@ -95,7 +131,7 @@ export const pickPreferredMCPClient = (
}
const ranked = items
.filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isActionableClient(item.client))
.filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isMCPClientKey(item.client))
.slice()
.sort((left, right) => {
const priorityDiff = getMCPClientPriority(left) - getMCPClientPriority(right);
@@ -120,3 +156,29 @@ export const formatMCPLaunchCommand = (
: [];
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
};
export const buildRemoteMCPClientGuide = (
status?: Pick<AIMCPClientInstallStatus, 'displayName' | 'message'> | null,
): string => {
const displayName = String(status?.displayName || '远程 Agent').trim();
return [
`GoNavi MCP 远程接入说明 - ${displayName}`,
'',
'目标:',
'- 数据库连接、账号和密码继续保存在 Windows 上的 GoNavi。云端 Agent 不需要保存数据库密码。',
'- 云端 Agent 只通过 MCP tools 读取 get_connections/get_databases/get_tables/get_columns/get_table_ddl 等结果。',
'- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。',
'',
'当前边界:',
'- GoNavi 内置 MCP 本机入口是 stdio适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。',
'- 如果 OpenClaw/Hermans 部署在云端 Linux不能直接使用 Windows 本地 stdio 命令;可在 Windows 上启动 GoNavi Streamable HTTP 模式,再通过隧道或反向代理给云端 Agent 调用。',
'',
'建议接入方式:',
'1. Windows 本机保持 GoNavi 可访问,由 GoNavi 读取保存连接和系统凭据。',
'2. 在 Windows 或可信内网侧运行GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>。',
`3. 在 ${displayName} 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 填隧道/反向代理后的 /mcp 地址,并设置 Authorization: Bearer <随机token>。`,
'4. 先调用 get_connections 获取 connectionId再调用表结构工具不要把数据库 host/user/password 写进云端 Agent 配置。',
'',
status?.message ? `当前提示:${status.message}` : '',
].filter((line, index, lines) => line || index < lines.length - 1).join('\n');
};

View File

@@ -21,8 +21,8 @@
/* ─── Light tokens ─────────────────────────────────────── */
body[data-ui-version="v2"][data-theme="light"] {
--gn-font-sans: "Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif;
--gn-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--gn-font-sans: "Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif;
--gn-font-mono: "JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--gn-bg-app: #f6f6f4;
--gn-bg-chrome: #ececea;
@@ -63,8 +63,8 @@ body[data-ui-version="v2"][data-theme="light"] {
/* ─── Dark tokens ──────────────────────────────────────── */
body[data-ui-version="v2"][data-theme="dark"] {
--gn-font-sans: "Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif;
--gn-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--gn-font-sans: "Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif;
--gn-font-mono: "JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
--gn-bg-app: #0c0e12;
--gn-bg-chrome: #14171c;

View File

@@ -25,6 +25,7 @@ export namespace ai {
export class MCPClientInstallStatus {
client: string;
displayName: string;
installMode?: string;
installed: boolean;
matchesCurrent: boolean;
clientDetected: boolean;
@@ -43,6 +44,7 @@ export namespace ai {
if ('string' === typeof source) source = JSON.parse(source);
this.client = source["client"];
this.displayName = source["displayName"];
this.installMode = source["installMode"];
this.installed = source["installed"];
this.matchesCurrent = source["matchesCurrent"];
this.clientDetected = source["clientDetected"];

View File

@@ -66,6 +66,8 @@ func (s *Service) AIGetMCPClientInstallStatuses() []ai.MCPClientInstallStatus {
return []ai.MCPClientInstallStatus{
inspectClaudeCodeMCPInstallStatus(command, args, resolveErr),
inspectCodexMCPInstallStatus(command, args, resolveErr),
buildRemoteMCPClientInstallStatus("openclaw", "OpenClaw"),
buildRemoteMCPClientInstallStatus("hermans", "Hermans"),
}
}
@@ -192,6 +194,7 @@ func inspectClaudeCodeMCPInstallStatus(expectedCommand string, expectedArgs []st
status := ai.MCPClientInstallStatus{
Client: "claude-code",
DisplayName: "Claude Code",
InstallMode: "auto",
ClientDetected: clientDetected,
ClientCommand: claudeCodeClientCommandName,
ClientPath: clientPath,
@@ -242,6 +245,7 @@ func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string,
status := ai.MCPClientInstallStatus{
Client: "codex",
DisplayName: "Codex",
InstallMode: "auto",
ClientDetected: clientDetected,
ClientCommand: codexClientCommandName,
ClientPath: clientPath,
@@ -286,6 +290,16 @@ func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string,
return status
}
func buildRemoteMCPClientInstallStatus(client string, displayName string) ai.MCPClientInstallStatus {
return ai.MCPClientInstallStatus{
Client: client,
DisplayName: displayName,
InstallMode: "remote",
ClientDetected: false,
Message: fmt.Sprintf("%s 通常部署在云端或远端环境;请通过远程 MCP 桥接接入 Windows GoNavi数据库密码仍保存在 GoNavi 本机。", displayName),
}
}
func readClaudeCodeMCPServerConfig(configPath string, serverID string) (claudeCodeMCPServerConfig, bool, error) {
root, err := readClaudeCodeConfig(configPath)
if err != nil {

View File

@@ -150,6 +150,7 @@ type MCPClientInstallResult struct {
type MCPClientInstallStatus struct {
Client string `json:"client"`
DisplayName string `json:"displayName"`
InstallMode string `json:"installMode,omitempty"`
Installed bool `json:"installed"`
MatchesCurrent bool `json:"matchesCurrent"`
ClientDetected bool `json:"clientDetected"`

View File

@@ -2,10 +2,32 @@ package mcpserver
import (
"context"
"crypto/subtle"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
defaultStreamableHTTPAddr = "127.0.0.1:8765"
defaultStreamableHTTPPath = "/mcp"
)
// HTTPServerOptions 描述远程 Streamable HTTP MCP 入口。
type HTTPServerOptions struct {
Addr string
Path string
Token string
JSONResponse bool
}
// RunAppStdioServer 启动基于真实 GoNavi App 的 stdio MCP server。
func RunAppStdioServer(ctx context.Context) error {
if ctx == nil {
@@ -27,3 +49,149 @@ func RunStdioServer(ctx context.Context, backend Backend) error {
server := NewServer(backend)
return server.Run(ctx, &mcp.StdioTransport{})
}
// RunAppStreamableHTTPServer 启动基于真实 GoNavi App 的 Streamable HTTP MCP server。
func RunAppStreamableHTTPServer(ctx context.Context, options HTTPServerOptions) error {
if ctx == nil {
ctx = context.Background()
}
backend := NewAppBackend(ctx)
defer backend.Close(ctx)
return RunStreamableHTTPServer(ctx, backend, options)
}
// RunStreamableHTTPServer 使用指定 backend 启动带 bearer token 的 Streamable HTTP MCP server。
func RunStreamableHTTPServer(ctx context.Context, backend Backend, options HTTPServerOptions) error {
if ctx == nil {
ctx = context.Background()
}
normalized, err := normalizeHTTPServerOptions(options)
if err != nil {
return err
}
server := NewServer(backend)
streamableHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, &mcp.StreamableHTTPOptions{
JSONResponse: normalized.JSONResponse,
SessionTimeout: 30 * time.Minute,
})
mux := http.NewServeMux()
mux.Handle(normalized.Path, bearerTokenAuthHandler(normalized.Token, streamableHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, "ok")
})
httpServer := &http.Server{
Addr: normalized.Addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- httpServer.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
}
// ParseHTTPServerOptions 解析 http 模式参数,并支持环境变量兜底。
func ParseHTTPServerOptions(args []string) (HTTPServerOptions, error) {
defaultAddr := strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_ADDR"))
if defaultAddr == "" {
defaultAddr = defaultStreamableHTTPAddr
}
defaultPath := strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_PATH"))
if defaultPath == "" {
defaultPath = defaultStreamableHTTPPath
}
options := HTTPServerOptions{
Addr: defaultAddr,
Path: defaultPath,
Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")),
JSONResponse: true,
}
fs := flag.NewFlagSet("gonavi-mcp-server http", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&options.Addr, "addr", options.Addr, "HTTP listen address, for example 127.0.0.1:8765")
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")
if err := fs.Parse(args); err != nil {
return HTTPServerOptions{}, err
}
if fs.NArg() > 0 {
return HTTPServerOptions{}, fmt.Errorf("未知 http 参数: %s", strings.Join(fs.Args(), " "))
}
return options, nil
}
func normalizeHTTPServerOptions(options HTTPServerOptions) (HTTPServerOptions, error) {
options.Addr = strings.TrimSpace(options.Addr)
if options.Addr == "" {
options.Addr = defaultStreamableHTTPAddr
}
options.Path = strings.TrimSpace(options.Path)
if options.Path == "" {
options.Path = defaultStreamableHTTPPath
}
if !strings.HasPrefix(options.Path, "/") {
options.Path = "/" + options.Path
}
options.Token = strings.TrimSpace(options.Token)
if options.Token == "" {
return HTTPServerOptions{}, errors.New("远程 MCP HTTP 模式必须设置 bearer token可使用 --token 或 GONAVI_MCP_HTTP_TOKEN")
}
return options, nil
}
func bearerTokenAuthHandler(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !hasBearerToken(req, token) {
w.Header().Set("WWW-Authenticate", `Bearer realm="GoNavi MCP"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, req)
})
}
func hasBearerToken(req *http.Request, token string) bool {
if req == nil {
return false
}
expected := strings.TrimSpace(token)
if expected == "" {
return false
}
header := strings.TrimSpace(req.Header.Get("Authorization"))
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return false
}
actual := strings.TrimSpace(header[len("bearer "):])
if actual == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}

View File

@@ -0,0 +1,87 @@
package mcpserver
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseHTTPServerOptionsSupportsFlagsAndEnvFallback(t *testing.T) {
t.Setenv("GONAVI_MCP_HTTP_ADDR", "127.0.0.1:9000")
t.Setenv("GONAVI_MCP_HTTP_PATH", "/env-mcp")
t.Setenv("GONAVI_MCP_HTTP_TOKEN", "env-token")
options, err := ParseHTTPServerOptions([]string{
"--addr", "0.0.0.0:8765",
"--path", "mcp",
"--token", "flag-token",
"--json-response=false",
})
if err != nil {
t.Fatalf("ParseHTTPServerOptions returned error: %v", err)
}
normalized, err := normalizeHTTPServerOptions(options)
if err != nil {
t.Fatalf("normalizeHTTPServerOptions returned error: %v", err)
}
if normalized.Addr != "0.0.0.0:8765" {
t.Fatalf("expected addr from flag, got %q", normalized.Addr)
}
if normalized.Path != "/mcp" {
t.Fatalf("expected normalized path /mcp, got %q", normalized.Path)
}
if normalized.Token != "flag-token" {
t.Fatalf("expected token from flag, got %q", normalized.Token)
}
if normalized.JSONResponse {
t.Fatal("expected json response flag to be false")
}
}
func TestNormalizeHTTPServerOptionsRequiresBearerToken(t *testing.T) {
_, err := normalizeHTTPServerOptions(HTTPServerOptions{Addr: "127.0.0.1:8765", Path: "/mcp"})
if err == nil || !strings.Contains(err.Error(), "bearer token") {
t.Fatalf("expected missing bearer token error, got %v", err)
}
}
func TestBearerTokenAuthHandlerRejectsMissingOrWrongToken(t *testing.T) {
called := false
handler := bearerTokenAuthHandler("secret-token", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/mcp", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected missing token to return 401, got %d", recorder.Code)
}
if called {
t.Fatal("next handler should not be called without token")
}
recorder = httptest.NewRecorder()
wrongReq := httptest.NewRequest(http.MethodPost, "/mcp", nil)
wrongReq.Header.Set("Authorization", "Bearer wrong")
handler.ServeHTTP(recorder, wrongReq)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected wrong token to return 401, got %d", recorder.Code)
}
if called {
t.Fatal("next handler should not be called with wrong token")
}
recorder = httptest.NewRecorder()
validReq := httptest.NewRequest(http.MethodPost, "/mcp", nil)
validReq.Header.Set("Authorization", "Bearer secret-token")
handler.ServeHTTP(recorder, validReq)
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected valid token to pass, got %d", recorder.Code)
}
if !called {
t.Fatal("next handler should be called with valid token")
}
}

24
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"os"
"strings"
@@ -78,12 +79,33 @@ func runSpecialMode(args []string) bool {
return false
}
if err := mcpserver.RunAppStdioServer(context.Background()); err != nil {
if err := runMCPServerMode(context.Background(), args[1:]); err != nil {
logger.Error(err, "GoNavi MCP Server 退出")
}
return true
}
func runMCPServerMode(ctx context.Context, args []string) error {
if len(args) == 0 {
return mcpserver.RunAppStdioServer(ctx)
}
mode := strings.ToLower(strings.TrimSpace(args[0]))
switch mode {
case "stdio", "--stdio":
return mcpserver.RunAppStdioServer(ctx)
case "http", "--http", "streamable-http", "--streamable-http":
options, err := mcpserver.ParseHTTPServerOptions(args[1:])
if err != nil {
return err
}
logger.Infof("GoNavi MCP Streamable HTTP Server 启动addr=%s path=%s", options.Addr, options.Path)
return mcpserver.RunAppStreamableHTTPServer(ctx, options)
default:
return fmt.Errorf("未知 MCP server 模式: %s支持 stdio/http", args[0])
}
}
func shouldRunMCPServerMode(args []string) bool {
if len(args) == 0 {
return false

View File

@@ -34,6 +34,7 @@ func TestShouldRunMCPServerMode(t *testing.T) {
{name: "empty", args: nil, want: false},
{name: "mcp-server", args: []string{"mcp-server"}, want: true},
{name: "flag style", args: []string{"--mcp-server"}, want: true},
{name: "mcp-server http mode", args: []string{"mcp-server", "http"}, want: true},
{name: "unknown", args: []string{"serve"}, want: false},
}