mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 03:59:41 +08:00
✨ feat(mcp): 支持远程 Agent 接入和 HTTP 模式
This commit is contained in:
@@ -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 客户端配置示例
|
||||
|
||||
开发态:
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 配置;OpenClaw、Hermans 这类云端 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 工具');
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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 远程接入说明');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}(外部工具)`;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -596,6 +596,7 @@ export interface AIMCPToolCallResult {
|
||||
export interface AIMCPClientInstallStatus {
|
||||
client: string;
|
||||
displayName: string;
|
||||
installMode?: 'auto' | 'remote';
|
||||
installed: boolean;
|
||||
matchesCurrent: boolean;
|
||||
clientDetected?: boolean;
|
||||
|
||||
@@ -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: {} },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
87
internal/mcpserver/run_test.go
Normal file
87
internal/mcpserver/run_test.go
Normal 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
24
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
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user