diff --git a/cmd/gonavi-mcp-server/README.md b/cmd/gonavi-mcp-server/README.md index 253a1f7..24d240a 100644 --- a/cmd/gonavi-mcp-server/README.md +++ b/cmd/gonavi-mcp-server/README.md @@ -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 Server,transport 选择 Streamable HTTP,URL 指向 `/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 客户端配置示例 开发态: diff --git a/cmd/gonavi-mcp-server/main.go b/cmd/gonavi-mcp-server/main.go index b91fc74..599ad08 100644 --- a/cmd/gonavi-mcp-server/main.go +++ b/cmd/gonavi-mcp-server/main.go @@ -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]) + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 1dfec7c..e48ea26 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx index 62c62f8..6bca8b8 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx @@ -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( + {}} + 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( = ({ onCopyConfigPath, onCopyLaunchCommand, onInstall, -}) => ( +}) => { + const selectedIsRemoteClient = isRemoteMCPClientStatus(selectedStatus); + + return (
= ({ }} >
- 这里是在把 GoNavi MCP 接入 Claude Code / Codex,给外部工具调用,不是给 GoNavi 自己安装插件。 + 这里是在把 GoNavi MCP 接入 Claude Code / Codex / OpenClaw / Hermans,给外部工具调用,不是给 GoNavi 自己安装插件。
- 这里的“安装”只会写入外部 CLI 的用户级 MCP 配置,让它知道如何启动当前这份 GoNavi MCP;不会重装 GoNavi,也不会替换 GoNavi 自己的程序文件。 + Claude Code 和 Codex 会写入本机用户级 MCP 配置;OpenClaw、Hermans 这类云端 Agent 会提供远程接入说明,避免把数据库密码复制到云端。
接入外部客户端
- 先在 Claude Code 和 Codex 中选择 1 个目标客户端,再执行安装或更新。每个选项会直接显示是否已接入当前 GoNavi,已接入时主按钮会禁用,避免重复写入。 + 先选择 1 个目标客户端。本机 CLI 可自动写入或更新配置;远程 Agent 需要通过 MCP 桥接/隧道访问当前 GoNavi,不应保存数据库连接密码。
= ({ }} > {[ - { 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) => (
= ({
-
选择外部客户端(二选一)
+
选择外部客户端
{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 = ({ {getMCPClientInstallStateLabel(status)}
- {active ? '当前已选中,将只对这个客户端执行写入或更新。' : '点击后切换到这个客户端。'} + {active + ? (remoteClient ? '当前已选中,将复制远程接入说明。' : '当前已选中,将只对这个客户端执行写入或更新。') + : (remoteClient ? '点击后查看远程接入方式。' : '点击后切换到这个客户端。')}
); @@ -265,10 +275,27 @@ const AIMCPClientInstallPanel: React.FC = ({
当前状态:{getSelectedMCPClientStateLine(selectedStatus)}
+ {selectedIsRemoteClient && ( +
+ 远程接入边界:数据库连接信息和密码仍保存在 Windows GoNavi;云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL。跨机器接入请使用 GoNavi Streamable HTTP 模式,并配合 token、隧道或反向代理。 +
+ )}
- CLI 检测:{selectedStatus?.clientDetected - ? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}` - : `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`} + CLI 检测:{selectedIsRemoteClient + ? `远程 Agent 不需要检测本机 ${resolveMCPClientCommandName(selectedStatus)} 命令` + : selectedStatus?.clientDetected + ? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}` + : `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`}
{selectedStatus?.clientPath && (
@@ -322,8 +349,12 @@ const AIMCPClientInstallPanel: React.FC = ({
{getMCPClientDetectionSummary(selectedStatus)} - {' '} - 已经接入当前这份 GoNavi 时,下面的主按钮会自动禁用,避免重复写入。 + {!selectedIsRemoteClient && ( + <> + {' '} + 已经接入当前这份 GoNavi 时,下面的主按钮会自动禁用,避免重复写入。 + + )}
-); + ); +}; export default AIMCPClientInstallPanel; diff --git a/frontend/src/components/ai/aiMCPInsights.ts b/frontend/src/components/ai/aiMCPInsights.ts index d0ac02b..22eb0ce 100644 --- a/frontend/src/components/ai/aiMCPInsights.ts +++ b/frontend/src/components/ai/aiMCPInsights.ts @@ -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, diff --git a/frontend/src/components/ai/aiSetupHealthInsights.ts b/frontend/src/components/ai/aiSetupHealthInsights.ts index 69cd84a..05e6c7c 100644 --- a/frontend/src/components/ai/aiSetupHealthInsights.ts +++ b/frontend/src/components/ai/aiSetupHealthInsights.ts @@ -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 Code 或 Codex'); + appendUnique(warnings, 'Claude Code / Codex 还没有本机客户端接入当前 GoNavi MCP,OpenClaw/Hermans 需要远程桥接'); + appendUnique(nextActions, '如需让外部 Agent 使用 GoNavi MCP,本机客户端可接入 Claude Code/Codex,云端 Agent 先配置远程 MCP 桥接'); } if (mcpSnapshot.enabledServerCount > 0 && runtimeSnapshot.mcpToolCount === 0) { appendUnique(warnings, '已启用 MCP 服务,但当前还没有发现可用 MCP 工具'); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index d72c1d2..e63e4f6 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -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'] }, diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts index fd96d54..f82297c 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts @@ -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 远程接入说明'); + }); }); diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.ts b/frontend/src/components/ai/mcpClientInstallPanelState.ts index 2b74e11..f08262f 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.ts @@ -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}(外部工具)`; }; diff --git a/frontend/src/components/ai/useAIMCPClientInstaller.ts b/frontend/src/components/ai/useAIMCPClientInstaller.ts index 3ffd229..614e7cd 100644 --- a/frontend/src/components/ai/useAIMCPClientInstaller.ts +++ b/frontend/src/components/ai/useAIMCPClientInstaller.ts @@ -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(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8044a38..41fd591 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -596,6 +596,7 @@ export interface AIMCPToolCallResult { export interface AIMCPClientInstallStatus { client: string; displayName: string; + installMode?: 'auto' | 'remote'; installed: boolean; matchesCurrent: boolean; clientDetected?: boolean; diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index 3bcd8f6..6195589 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -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: {} }, }, }, diff --git a/frontend/src/utils/fontFamilies.ts b/frontend/src/utils/fontFamilies.ts index 997169b..cd2e4e6 100644 --- a/frontend/src/utils/fontFamilies.ts +++ b/frontend/src/utils/fontFamilies.ts @@ -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[] = [ diff --git a/frontend/src/utils/mcpClientInstallStatus.test.ts b/frontend/src/utils/mcpClientInstallStatus.test.ts index 1b4dafc..144ef7a 100644 --- a/frontend/src/utils/mcpClientInstallStatus.test.ts +++ b/frontend/src/utils/mcpClientInstallStatus.test.ts @@ -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'); + }); }); diff --git a/frontend/src/utils/mcpClientInstallStatus.ts b/frontend/src/utils/mcpClientInstallStatus.ts index 52698de..ae98798 100644 --- a/frontend/src/utils/mcpClientInstallStatus.ts +++ b/frontend/src/utils/mcpClientInstallStatus.ts @@ -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(['claude-code', 'codex']); +const REMOTE_MCP_CLIENTS = new Set(['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 | null): boolean => { + const client = String(status?.client || '').trim(); + return status?.installMode === 'remote' || (isMCPClientKey(client) && REMOTE_MCP_CLIENTS.has(client)); +}; + +export const supportsAutoMCPClientInstall = (status?: Pick | 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 | 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 Server,transport 选择 Streamable HTTP,URL 填隧道/反向代理后的 /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'); +}; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 99c70fe..da250b0 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5d135ee..bf9129f 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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"]; diff --git a/internal/ai/service/claude_code_mcp.go b/internal/ai/service/claude_code_mcp.go index e7bdef6..dda796e 100644 --- a/internal/ai/service/claude_code_mcp.go +++ b/internal/ai/service/claude_code_mcp.go @@ -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 { diff --git a/internal/ai/types.go b/internal/ai/types.go index 97afee7..b4b4ef8 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -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"` diff --git a/internal/mcpserver/run.go b/internal/mcpserver/run.go index 3374c97..7346612 100644 --- a/internal/mcpserver/run.go +++ b/internal/mcpserver/run.go @@ -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 +} diff --git a/internal/mcpserver/run_test.go b/internal/mcpserver/run_test.go new file mode 100644 index 0000000..753c22d --- /dev/null +++ b/internal/mcpserver/run_test.go @@ -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") + } +} diff --git a/main.go b/main.go index 1629c79..bc9e759 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/main_test.go b/main_test.go index e64224e..56d918f 100644 --- a/main_test.go +++ b/main_test.go @@ -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}, }