From 26fb650e046dec16e547fdc3ef1d17865392b7b4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 11 Jun 2026 07:29:04 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=A2=9E=E5=BC=BA=20MC?= =?UTF-8?q?P=20=E8=BF=9C=E7=A8=8B=E6=8E=A5=E5=85=A5=E4=B8=8E=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 + README.zh-CN.md | 10 + cmd/gonavi-mcp-server/README.md | 12 + cmd/gonavi-mcp-server/main.go | 4 +- .../ai/AIBuiltinToolsCatalog.test.tsx | 3 + .../ai/AIMCPClientInstallPanel.test.tsx | 2 + .../components/ai/AIMCPClientInstallPanel.tsx | 30 ++- .../ai/aiContextBudgetInsights.test.ts | 91 +++++++ .../components/ai/aiContextBudgetInsights.ts | 246 ++++++++++++++++++ ...ToolExecutor.localAssetsInspection.test.ts | 55 ++++ .../ai/aiSnapshotInspectionToolExecutor.ts | 18 ++ .../ai/aiSystemContextMessages.test.ts | 3 +- .../ai/aiSystemInspectionGuidance.ts | 6 + .../messageBubble/AIMessageStatusBlocks.tsx | 1 + .../src/utils/aiBuiltinInspectionToolInfo.ts | 24 ++ frontend/src/utils/aiBuiltinToolCatalog.ts | 5 + frontend/src/utils/aiToolRegistry.test.ts | 9 + .../src/utils/mcpClientInstallStatus.test.ts | 3 + frontend/src/utils/mcpClientInstallStatus.ts | 11 +- internal/mcpserver/remote_config.go | 215 +++++++++++++++ internal/mcpserver/remote_config_test.go | 83 ++++++ internal/mcpserver/service.go | 41 ++- internal/mcpserver/service_test.go | 44 ++++ main.go | 4 +- main_test.go | 1 + 25 files changed, 923 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/ai/aiContextBudgetInsights.test.ts create mode 100644 frontend/src/components/ai/aiContextBudgetInsights.ts create mode 100644 internal/mcpserver/remote_config.go create mode 100644 internal/mcpserver/remote_config_test.go diff --git a/README.md b/README.md index 7036953..cd75c60 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,16 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0 If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+. +### Linux: Chinese text appears as square boxes + +Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi: + +```bash +sudo apt-get update +sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei +fc-cache -fv +``` + --- ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 8cc2f58..e8f9627 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -196,6 +196,16 @@ sudo apt-get update sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18 ``` +### Linux 中文显示为方框 + +Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体,GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi: + +```bash +sudo apt-get update +sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei +fc-cache -fv +``` + --- ## 贡献指南 diff --git a/cmd/gonavi-mcp-server/README.md b/cmd/gonavi-mcp-server/README.md index 8e88217..3f65b7d 100644 --- a/cmd/gonavi-mcp-server/README.md +++ b/cmd/gonavi-mcp-server/README.md @@ -55,6 +55,18 @@ go run ./cmd/gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp 默认建议只监听 `127.0.0.1`,再通过 SSH 隧道、反向代理或内网网关暴露给云端 Agent。不要在没有 TLS、防火墙和鉴权的情况下直接监听公网地址。 +无图形界面或需要把配置交给云端 Agent 时,可直接生成 OpenClaw / Hermans 等远程 MCP 配置: + +```powershell +& "C:\Program Files\GoNavi\GoNavi.exe" mcp-server remote-config --client openclaw --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" +``` + +独立 server 开发态也支持同样能力: + +```powershell +go run ./cmd/gonavi-mcp-server remote-config --client hermans --url "https://<你的域名或隧道地址>/mcp" --token "<随机token>" +``` + ## Claude Code / Codex / OpenClaw / Hermans 正式安装包场景,推荐直接在 GoNavi 里使用“AI 设置 -> MCP 服务 -> 安装到 Claude Code / 安装到 Codex”。 diff --git a/cmd/gonavi-mcp-server/main.go b/cmd/gonavi-mcp-server/main.go index 599ad08..9a5c54b 100644 --- a/cmd/gonavi-mcp-server/main.go +++ b/cmd/gonavi-mcp-server/main.go @@ -34,7 +34,9 @@ func run(ctx context.Context, args []string) error { } log.Printf("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s", options.Addr, options.Path) return mcpserver.RunAppStreamableHTTPServer(ctx, options) + case "remote-config", "--remote-config": + return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:]) default: - return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http)", args[0]) + return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http/remote-config)", args[0]) } } diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 567a942..46d4e7e 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -88,6 +88,9 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_ai_last_render_error'); expect(markup).toContain('诊断 AI 消息流'); expect(markup).toContain('inspect_ai_message_flow'); + expect(markup).toContain('诊断 AI 上下文体量'); + expect(markup).toContain('inspect_ai_context_budget'); + expect(markup).toContain('messageLimit'); expect(markup).toContain('复用历史 SQL'); expect(markup).toContain('inspect_saved_queries'); expect(markup).toContain('回看 AI 历史对话'); diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx index 7960c2f..a895972 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx @@ -194,9 +194,11 @@ describe('AIMCPClientInstallPanel', () => { expect(markup).toContain('云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL'); expect(markup).toContain('OpenClaw 远程 MCP 快速配置'); expect(markup).toContain('配置到云端 Agent'); + expect(markup).toContain('无 GUI / CLI 生成配置'); expect(markup).toContain('"type": "streamable-http"'); expect(markup).toContain('"url": "https://<你的域名或隧道地址>/mcp"'); expect(markup).toContain('"Authorization": "Bearer <随机token>"'); + expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>'); expect(markup).toContain('Windows 启动 GoNavi MCP HTTP'); expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); expect(markup).toContain('独立二进制:gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx index f997465..4aa4a7f 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx @@ -310,7 +310,7 @@ const AIMCPClientInstallPanel: React.FC = ({ {remoteQuickStart.displayName} 远程 MCP 快速配置
- 下面两段分别给云端 Agent 和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。 + 下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。
= ({ {remoteQuickStart.configJson}
+
+
+ 无 GUI / CLI 生成配置 +
+ + {remoteQuickStart.configCommand} + +
+ 用于生成可粘贴到 {remoteQuickStart.displayName} 的远程 MCP 配置,不会读取或输出数据库密码。 +
+
{ + it('summarizes context budget sources and warns when schema/tool results are oversized', () => { + const longToolResult = 'x'.repeat(22000); + const longDDL = `CREATE TABLE big_table (${Array.from({ length: 300 }, (_, index) => `c${index} varchar(255)`).join(',')})`; + const snapshot = buildAIContextBudgetSnapshot({ + activeSessionId: 'session-1', + aiChatSessions: [{ id: 'session-1', title: '上下文体量排查', updatedAt: 1 }], + aiChatHistory: { + 'session-1': [ + { id: 'msg-1', role: 'user', content: 'AI 变慢了', timestamp: 1 }, + { + id: 'msg-2', + role: 'assistant', + content: '先看日志', + timestamp: 2, + tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_app_logs', arguments: '{}' } }], + }, + { id: 'msg-3', role: 'tool', content: longToolResult, timestamp: 3, tool_call_id: 'tool-1' }, + ], + }, + aiContexts: { + 'conn-1:crm': [ + { dbName: 'crm', tableName: 'big_table', ddl: longDDL }, + ], + }, + mcpTools: [{ + alias: 'remote_probe', + serverId: 'mcp-1', + serverName: 'demo', + originalName: 'probe', + inputSchema: { + type: 'object', + properties: Object.fromEntries(Array.from({ length: 20 }, (_, index) => [`field${index}`, { type: 'string' }])), + }, + }], + skills: [{ + id: 'skill-1', + name: '结构审查', + systemPrompt: '先看结构', + enabled: true, + scopes: ['database'], + }], + userPromptSettings: { + global: '全局提示', + database: '数据库提示', + jvm: '', + jvmDiagnostic: '', + }, + }); + + expect(snapshot.foundSession).toBe(true); + expect(snapshot.title).toBe('上下文体量排查'); + expect(snapshot.messageWindow.toolResultChars).toBe(22000); + expect(snapshot.messageWindow.unresolvedToolCallCount).toBe(0); + expect(snapshot.schemaContext.tableCount).toBe(1); + expect(snapshot.schemaContext.largestTables[0]).toMatchObject({ tableName: 'big_table' }); + expect(snapshot.toolCatalog.mcpToolCount).toBe(1); + expect(snapshot.promptsAndSkills.enabledSkillNames).toContain('结构审查'); + expect(snapshot.warnings).toContain('最近工具结果较长,可能导致后续回答被日志或大结果集稀释'); + expect(snapshot.nextActions).toContain('降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量'); + }); + + it('reports missing sessions and unresolved tool calls', () => { + const snapshot = buildAIContextBudgetSnapshot({ + activeSessionId: 'missing', + aiChatSessions: [], + aiChatHistory: { + missing: [ + { + id: 'msg-1', + role: 'assistant', + content: '调用工具', + timestamp: 1, + tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_ai_runtime', arguments: '{}' } }], + }, + ], + }, + includeDetails: false, + }); + + expect(snapshot.foundSession).toBe(false); + expect(snapshot.messageWindow.unresolvedToolCallCount).toBe(1); + expect(snapshot.warnings).toContain('未找到目标 AI 会话,消息体量统计只覆盖空窗口'); + expect(snapshot.warnings).toContain('最近消息窗口内有 1 个未闭环工具调用'); + expect(snapshot.nextActions).toContain('先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息'); + }); +}); diff --git a/frontend/src/components/ai/aiContextBudgetInsights.ts b/frontend/src/components/ai/aiContextBudgetInsights.ts new file mode 100644 index 0000000..51dcffe --- /dev/null +++ b/frontend/src/components/ai/aiContextBudgetInsights.ts @@ -0,0 +1,246 @@ +import type { + AIChatMessage, + AIContextItem, + AIMCPToolDescriptor, + AISkillConfig, + AIUserPromptSettings, +} from '../../types'; + +type ContextRiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +interface BuildAIContextBudgetSnapshotOptions { + aiContexts?: Record; + aiChatHistory?: Record; + aiChatSessions?: Array<{ id: string; title: string; updatedAt: number }>; + activeSessionId?: string | null; + sessionId?: unknown; + messageLimit?: unknown; + includeDetails?: unknown; + mcpTools?: AIMCPToolDescriptor[]; + skills?: AISkillConfig[]; + userPromptSettings?: AIUserPromptSettings; +} + +const DEFAULT_MESSAGE_LIMIT = 40; +const MAX_MESSAGE_LIMIT = 120; +const MESSAGE_PREVIEW_LIMIT = 160; + +const clampNumber = (value: unknown, fallback: number, min: number, max: number): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.round(parsed))); +}; + +const charCount = (value: unknown): number => String(value || '').length; + +const estimateTokens = (chars: number): number => Math.ceil(Math.max(0, chars) / 3); + +const previewText = (value: unknown, limit = MESSAGE_PREVIEW_LIMIT): string => { + const normalized = String(value || '').replace(/\s+/g, ' ').trim(); + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit)}...`; +}; + +const classifyRisk = (estimatedInputChars: number): ContextRiskLevel => { + if (estimatedInputChars >= 120000) { + return 'critical'; + } + if (estimatedInputChars >= 70000) { + return 'high'; + } + if (estimatedInputChars >= 30000) { + return 'medium'; + } + return 'low'; +}; + +const appendUnique = (items: string[], item: string) => { + if (!items.includes(item)) { + items.push(item); + } +}; + +const getMessagePayloadChars = (message: AIChatMessage): number => + charCount(message.content) + + charCount(message.thinking) + + charCount(message.reasoning_content) + + (message.tool_calls ? charCount(JSON.stringify(message.tool_calls)) : 0); + +export const buildAIContextBudgetSnapshot = ({ + aiContexts = {}, + aiChatHistory = {}, + aiChatSessions = [], + activeSessionId = null, + sessionId, + messageLimit, + includeDetails, + mcpTools = [], + skills = [], + userPromptSettings, +}: BuildAIContextBudgetSnapshotOptions) => { + const requestedSessionId = String(sessionId || activeSessionId || '').trim(); + const allMessages = requestedSessionId ? (aiChatHistory[requestedSessionId] || []) : []; + const effectiveMessageLimit = clampNumber(messageLimit, DEFAULT_MESSAGE_LIMIT, 1, MAX_MESSAGE_LIMIT); + const inspectedMessages = allMessages.slice(-effectiveMessageLimit); + const shouldIncludeDetails = includeDetails !== false; + const session = aiChatSessions.find((item) => item.id === requestedSessionId); + + const messageRoleCounts = inspectedMessages.reduce>((acc, message) => { + acc[message.role] = (acc[message.role] || 0) + 1; + return acc; + }, {}); + const messagePayloadChars = inspectedMessages.reduce((sum, message) => sum + getMessagePayloadChars(message), 0); + const toolResultChars = inspectedMessages + .filter((message) => message.role === 'tool') + .reduce((sum, message) => sum + charCount(message.content), 0); + const thinkingChars = inspectedMessages.reduce( + (sum, message) => sum + charCount(message.thinking) + charCount(message.reasoning_content), + 0, + ); + const unresolvedToolCallIds = new Set(); + inspectedMessages.forEach((message) => { + (message.tool_calls || []).forEach((toolCall) => unresolvedToolCallIds.add(toolCall.id)); + if (message.role === 'tool' && message.tool_call_id) { + unresolvedToolCallIds.delete(message.tool_call_id); + } + }); + + const contextEntries = Object.entries(aiContexts).flatMap(([contextKey, items]) => ( + (items || []).map((item) => ({ contextKey, item })) + )); + const ddlChars = contextEntries.reduce((sum, entry) => sum + charCount(entry.item.ddl), 0); + const largestTables = contextEntries + .map((entry) => ({ + contextKey: entry.contextKey, + dbName: entry.item.dbName, + tableName: entry.item.tableName, + ddlChars: charCount(entry.item.ddl), + })) + .sort((left, right) => right.ddlChars - left.ddlChars) + .slice(0, shouldIncludeDetails ? 8 : 3); + + const mcpSchemaChars = mcpTools.reduce((sum, tool) => ( + sum + + charCount(tool.alias) + + charCount(tool.description) + + charCount(tool.inputSchema ? JSON.stringify(tool.inputSchema) : '') + ), 0); + const largestMCPTools = [...mcpTools] + .map((tool) => ({ + alias: tool.alias, + serverName: tool.serverName, + schemaChars: charCount(tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''), + })) + .sort((left, right) => right.schemaChars - left.schemaChars) + .slice(0, shouldIncludeDetails ? 8 : 3); + + const enabledSkills = skills.filter((skill) => skill.enabled); + const skillPromptChars = enabledSkills.reduce((sum, skill) => ( + sum + charCount(skill.name) + charCount(skill.description) + charCount(skill.systemPrompt) + ), 0); + const userPromptChars = userPromptSettings + ? charCount(userPromptSettings.global) + + charCount(userPromptSettings.database) + + charCount(userPromptSettings.jvm) + + charCount(userPromptSettings.jvmDiagnostic) + : 0; + + const estimatedInputChars = messagePayloadChars + ddlChars + mcpSchemaChars + skillPromptChars + userPromptChars; + const riskLevel = classifyRisk(estimatedInputChars); + const warnings: string[] = []; + const nextActions: string[] = []; + + if (!requestedSessionId || !session) { + appendUnique(warnings, '未找到目标 AI 会话,消息体量统计只覆盖空窗口'); + appendUnique(nextActions, '先打开或选中目标 AI 会话,再重新调用 inspect_ai_context_budget'); + } + if (riskLevel === 'critical') { + appendUnique(warnings, '当前 AI 输入上下文体量达到 critical,可能导致回复慢、截断或模型忽略关键约束'); + } else if (riskLevel === 'high') { + appendUnique(warnings, '当前 AI 输入上下文体量偏高,复杂问题前建议先收窄上下文'); + } + if (ddlChars >= 60000 || contextEntries.length >= 30) { + appendUnique(warnings, '已挂载表结构较多或 DDL 较长,可能挤占用户问题和工具结果空间'); + appendUnique(nextActions, '只保留本轮相关表,必要时改用 inspect_table_bundle 按需读取目标表'); + } + if (messagePayloadChars >= 40000) { + appendUnique(warnings, '当前会话最近消息内容较长,可能影响后续回复稳定性'); + appendUnique(nextActions, '新开会话或先让 AI 总结当前结论,再继续下一轮复杂任务'); + } + if (toolResultChars >= 20000) { + appendUnique(warnings, '最近工具结果较长,可能导致后续回答被日志或大结果集稀释'); + appendUnique(nextActions, '降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量'); + } + if (mcpTools.length >= 40 || mcpSchemaChars >= 30000) { + appendUnique(warnings, '当前暴露的 MCP 工具或 schema 较多,模型选择工具时可能更容易走偏'); + appendUnique(nextActions, '临时禁用无关 MCP 服务,或先调用 inspect_ai_tool_catalog 按关键词收窄工具路线'); + } + if (enabledSkills.length >= 8 || skillPromptChars >= 16000) { + appendUnique(warnings, '当前启用 Skills 较多或提示词较长,可能叠加冲突约束'); + appendUnique(nextActions, '仅保留本轮任务相关 Skills,完成后再恢复其它 Skills'); + } + if (unresolvedToolCallIds.size > 0) { + appendUnique(warnings, `最近消息窗口内有 ${unresolvedToolCallIds.size} 个未闭环工具调用`); + appendUnique(nextActions, '先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息'); + } + if (warnings.length === 0) { + appendUnique(nextActions, '当前上下文体量可控,可继续按具体问题调用更窄的结构、日志或 SQL 风险探针'); + } + + const largestMessages = inspectedMessages + .map((message) => ({ + id: message.id, + role: message.role, + chars: getMessagePayloadChars(message), + preview: shouldIncludeDetails ? previewText(message.content) : undefined, + })) + .sort((left, right) => right.chars - left.chars) + .slice(0, shouldIncludeDetails ? 8 : 3); + + return { + requestedSessionId: requestedSessionId || null, + activeSessionId, + foundSession: Boolean(session), + title: session?.title || '', + riskLevel, + estimatedInputChars, + estimatedInputTokens: estimateTokens(estimatedInputChars), + messageWindow: { + totalMessages: allMessages.length, + inspectedMessages: inspectedMessages.length, + limit: effectiveMessageLimit, + roleCounts: messageRoleCounts, + payloadChars: messagePayloadChars, + thinkingChars, + toolResultChars, + unresolvedToolCallCount: unresolvedToolCallIds.size, + largestMessages, + }, + schemaContext: { + contextCount: Object.keys(aiContexts).length, + tableCount: contextEntries.length, + ddlChars, + estimatedTokens: estimateTokens(ddlChars), + largestTables, + }, + toolCatalog: { + mcpToolCount: mcpTools.length, + mcpSchemaChars, + estimatedTokens: estimateTokens(mcpSchemaChars), + largestMCPTools, + }, + promptsAndSkills: { + userPromptChars, + enabledSkillCount: enabledSkills.length, + skillPromptChars, + estimatedTokens: estimateTokens(userPromptChars + skillPromptChars), + enabledSkillNames: enabledSkills.map((skill) => skill.name).slice(0, shouldIncludeDetails ? 20 : 8), + }, + warnings, + nextActions, + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.localAssetsInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.localAssetsInspection.test.ts index a874a28..6fd2241 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.localAssetsInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.localAssetsInspection.test.ts @@ -139,6 +139,61 @@ describe('aiLocalToolExecutor local asset inspection tools', () => { expect(result.content).toContain('回复拆成多个气泡'); }); + it('returns ai context budget diagnostics for the active session', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_context_budget', { + messageLimit: 10, + }), + connections: [buildConnection()], + mcpTools: [{ + alias: 'remote_probe', + originalName: 'remote_probe', + serverId: 'server-1', + serverName: '远程工具', + inputSchema: { + type: 'object', + properties: { + keyword: { type: 'string' }, + }, + }, + }], + toolContextMap: new Map(), + aiChatSessions: [ + { id: 'session-1', title: '上下文预算排查', updatedAt: 200 }, + ], + aiChatHistory: { + 'session-1': [ + { id: 'msg-1', role: 'user', content: 'AI 变慢是不是上下文太大', timestamp: 101 }, + { id: 'msg-2', role: 'tool', content: 'x'.repeat(21000), timestamp: 102, tool_call_id: 'tool-1' }, + ], + }, + activeSessionId: 'session-1', + aiContexts: { + 'conn-1:crm': [ + { dbName: 'crm', tableName: 'orders', ddl: 'CREATE TABLE orders(id bigint, amount decimal(10,2));' }, + ], + }, + skills: [{ + id: 'skill-1', + name: 'SQL 审查', + systemPrompt: '先检查风险', + enabled: true, + scopes: ['database'], + }], + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"title":"上下文预算排查"'); + expect(result.content).toContain('"toolResultChars":21000'); + expect(result.content).toContain('"tableName":"orders"'); + expect(result.content).toContain('"mcpToolCount":1'); + expect(result.content).toContain('最近工具结果较长'); + }); + it('returns sql snippets so the model can inspect local query templates', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_sql_snippets', { diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index fbc5980..8566ca8 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -16,6 +16,7 @@ import { buildAIChatSessionsSnapshot, buildAIMessageFlowSnapshot, } from './aiChatSessionInsights'; +import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights'; import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; import { buildCurrentConnectionSnapshot } from './aiConnectionInsights'; import { @@ -280,6 +281,22 @@ export async function executeSnapshotInspectionToolCall( })), success: true, }; + case 'inspect_ai_context_budget': + return { + content: JSON.stringify(buildAIContextBudgetSnapshot({ + aiContexts, + aiChatHistory, + aiChatSessions, + activeSessionId, + sessionId: args.sessionId, + messageLimit: args.messageLimit, + includeDetails: args.includeDetails, + mcpTools, + skills, + userPromptSettings, + })), + success: true, + }; case 'inspect_recent_sql_logs': return { content: JSON.stringify(buildRecentSqlLogsSnapshot({ @@ -426,6 +443,7 @@ export async function executeSnapshotInspectionToolCall( inspect_recent_connection_failures: '汇总最近连接失败记录失败', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败', inspect_ai_message_flow: '读取 AI 消息流诊断失败', + inspect_ai_context_budget: '读取 AI 上下文体量诊断失败', inspect_saved_queries: '读取已保存查询失败', inspect_sql_snippets: '读取 SQL 片段失败', inspect_shortcuts: '读取快捷键配置失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 1573734..fdfc0d0 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => { connections: [connections[0]], tabs: [], activeTabId: null, - availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); @@ -100,6 +100,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部'); expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录'); expect(joined).toContain('inspect_ai_message_flow 读取当前会话的真实消息结构'); + expect(joined).toContain('inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险'); expect(joined).toContain('inspect_saved_queries'); expect(joined).toContain('inspect_ai_sessions'); expect(joined).toContain('inspect_sql_snippets'); diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index 5a7edb0..9047638 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -146,6 +146,12 @@ export const appendDatabaseInspectionGuidanceMessages = ( 'inspect_ai_message_flow', '如果用户提到“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流状态不对”“同一轮回答没有追加到同一个气泡”,优先调用 inspect_ai_message_flow 读取当前会话的真实消息结构、连续 assistant 消息和未闭环工具调用,不要只凭界面现象猜测。', ); + appendGuidanceIfToolAvailable( + messages, + availableToolNames, + 'inspect_ai_context_budget', + '如果用户提到“AI 变慢”“上下文太大”“表结构挂太多”“工具结果太长”“模型开始乱答”或复杂任务前需要判断是否该拆小上下文,优先调用 inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险,再决定收窄上下文或拆任务。', + ); appendGuidanceIfToolAvailable( messages, availableToolNames, diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 4411a19..efbc796 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -59,6 +59,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_recent_connection_failures: '总结最近连接失败记录', inspect_ai_last_render_error: '读取最近一次 AI 渲染异常', inspect_ai_message_flow: '诊断当前 AI 消息流', + inspect_ai_context_budget: '诊断 AI 上下文体量风险', inspect_saved_queries: '检索本地已保存查询', inspect_sql_snippets: '读取 SQL 片段模板', inspect_shortcuts: '读取当前快捷键配置', diff --git a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts index ccfbbe0..708bb00 100644 --- a/frontend/src/utils/aiBuiltinInspectionToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionToolInfo.ts @@ -701,6 +701,30 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_ai_context_budget", + icon: "📦", + desc: "诊断 AI 上下文体量与稳定性风险", + detail: + "统计当前或指定 AI 会话的最近消息、工具结果、已挂载表结构、MCP 工具 schema、用户提示词和 Skills 体量,返回 low/medium/high/critical 风险、主要膨胀来源和收窄建议。适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时先做预算体检。", + params: "sessionId?(默认当前会话), messageLimit?(默认 40), includeDetails?(默认 true)", + tool: { + type: "function", + function: { + name: "inspect_ai_context_budget", + description: + "读取当前 AI 上下文体量与稳定性风险快照,包括最近消息窗口、tool 结果长度、已挂载表结构 DDL、MCP 工具 schema、用户提示词和启用 Skills 的估算体量,并返回风险级别、告警和收窄建议。适用于用户提到 AI 回复变慢、上下文过大、表结构带太多、工具结果过长、模型开始乱答或复杂任务前需要判断是否应拆小上下文时优先调用。", + parameters: { + type: "object", + properties: { + sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID;不传时读取当前活动会话" }, + messageLimit: { type: "number", description: "可选,最多统计最近多少条消息,默认 40,最大 120" }, + includeDetails: { type: "boolean", description: "可选,是否返回最大消息、最大 DDL 表和最大 MCP schema 明细,默认 true" }, + }, + }, + }, + }, + }, { name: "inspect_sql_snippets", icon: "🧩", diff --git a/frontend/src/utils/aiBuiltinToolCatalog.ts b/frontend/src/utils/aiBuiltinToolCatalog.ts index 3b57f71..be53a42 100644 --- a/frontend/src/utils/aiBuiltinToolCatalog.ts +++ b/frontend/src/utils/aiBuiltinToolCatalog.ts @@ -184,6 +184,11 @@ export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [ steps: 'inspect_ai_message_flow -> inspect_ai_last_render_error / inspect_app_logs', description: '适合用户反馈回复被拆成多个气泡、工具调用后没继续回答、消息流状态不对时,先读取当前会话的真实消息结构和异常信号。', }, + { + title: '诊断 AI 上下文体量', + steps: 'inspect_ai_context_budget -> inspect_ai_context / inspect_ai_message_flow / inspect_ai_tool_catalog', + description: '适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时,先看消息、DDL、MCP schema、提示词和 Skills 的体量来源,再决定收窄上下文或拆任务。', + }, { title: '复用历史 SQL', steps: 'inspect_saved_queries -> get_columns / execute_sql', diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 3c3c3e7..53790b3 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -162,6 +162,14 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('连续 assistant 消息'); }); + it('registers the ai-context-budget inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_context_budget'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('上下文体量'); + expect(info?.tool.function.description).toContain('MCP 工具 schema'); + expect(info?.tool.function.parameters?.properties?.messageLimit?.description).toContain('最大 120'); + }); + it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => { const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity'); const sqlEditorTransactionTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_editor_transaction'); @@ -236,6 +244,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_message_flow')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_ai_context_budget')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true); diff --git a/frontend/src/utils/mcpClientInstallStatus.test.ts b/frontend/src/utils/mcpClientInstallStatus.test.ts index 6468e83..78f17e8 100644 --- a/frontend/src/utils/mcpClientInstallStatus.test.ts +++ b/frontend/src/utils/mcpClientInstallStatus.test.ts @@ -137,11 +137,13 @@ describe('mcpClientInstallStatus helpers', () => { expect(guide).toContain('allowMutating=true'); expect(guide).toContain('"type": "streamable-http"'); expect(guide).toContain('"Authorization": "Bearer <随机token>"'); + expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>'); expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); }); it('builds remote quick-start snippets for cloud agents without database secrets', () => { const quickStart = buildRemoteMCPClientQuickStart({ + client: 'hermans', displayName: 'OpenClaw', }); @@ -150,6 +152,7 @@ describe('mcpClientInstallStatus helpers', () => { expect(quickStart.configJson).toContain('"url": "https://<你的域名或隧道地址>/mcp"'); expect(quickStart.configJson).toContain('"Authorization": "Bearer <随机token>"'); expect(quickStart.configJson).not.toContain('password'); + expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token>'); expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>'); expect(quickStart.verificationSteps.join('\n')).toContain('get_connections'); diff --git a/frontend/src/utils/mcpClientInstallStatus.ts b/frontend/src/utils/mcpClientInstallStatus.ts index a256fc8..7c2836c 100644 --- a/frontend/src/utils/mcpClientInstallStatus.ts +++ b/frontend/src/utils/mcpClientInstallStatus.ts @@ -11,6 +11,7 @@ const DEFAULT_REMOTE_MCP_PATH = '/mcp'; export interface RemoteMCPClientQuickStart { displayName: string; configJson: string; + configCommand: string; launchCommand: string; standaloneCommand: string; verificationSteps: string[]; @@ -170,7 +171,7 @@ export const formatMCPLaunchCommand = ( }; export const buildRemoteMCPClientGuide = ( - status?: Pick | null, + status?: Partial> | null, ): string => { const quickStart = buildRemoteMCPClientQuickStart(status); return [ @@ -194,6 +195,9 @@ export const buildRemoteMCPClientGuide = ( '可复制配置片段(适用于支持 mcpServers JSON 的 Agent):', ...quickStart.configJson.split('\n'), '', + '无 GUI / CLI 生成配置命令:', + quickStart.configCommand, + '', 'CLI / 服务启动命令:', quickStart.launchCommand, `或设置环境变量:GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`, @@ -203,11 +207,13 @@ export const buildRemoteMCPClientGuide = ( }; export const buildRemoteMCPClientQuickStart = ( - status?: Pick | null, + status?: Partial> | null, ): RemoteMCPClientQuickStart => { const displayName = String(status?.displayName || '远程 Agent').trim(); + const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw'; const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`; const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`; + const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token>`; const configJson = JSON.stringify({ mcpServers: { gonavi: { @@ -223,6 +229,7 @@ export const buildRemoteMCPClientQuickStart = ( return { displayName, configJson, + configCommand, launchCommand, standaloneCommand, verificationSteps: [ diff --git a/internal/mcpserver/remote_config.go b/internal/mcpserver/remote_config.go new file mode 100644 index 0000000..952319e --- /dev/null +++ b/internal/mcpserver/remote_config.go @@ -0,0 +1,215 @@ +package mcpserver + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" +) + +const ( + defaultRemoteMCPPublicURL = "https://<你的域名或隧道地址>/mcp" + defaultRemoteMCPServerID = "gonavi" + defaultRemoteMCPTokenHint = "<随机token>" +) + +// RemoteMCPClientConfigOptions 描述给云端 Agent 生成远程 MCP 配置的参数。 +type RemoteMCPClientConfigOptions struct { + Client string + DisplayName string + URL string + Token string + ServerID string + LocalAddr string + Path string + GoNaviCommand string + StandaloneCommand string +} + +// ParseRemoteMCPClientConfigOptions 解析 remote-config 模式参数。 +func ParseRemoteMCPClientConfigOptions(args []string) (RemoteMCPClientConfigOptions, error) { + options := RemoteMCPClientConfigOptions{ + Client: "openclaw", + URL: strings.TrimSpace(os.Getenv("GONAVI_MCP_PUBLIC_URL")), + Token: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_TOKEN")), + ServerID: defaultRemoteMCPServerID, + LocalAddr: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_ADDR")), + Path: strings.TrimSpace(os.Getenv("GONAVI_MCP_HTTP_PATH")), + GoNaviCommand: "GoNavi.exe", + StandaloneCommand: "gonavi-mcp-server", + } + if options.URL == "" { + options.URL = defaultRemoteMCPPublicURL + } + if options.Token == "" { + options.Token = defaultRemoteMCPTokenHint + } + if options.LocalAddr == "" { + options.LocalAddr = defaultStreamableHTTPAddr + } + if options.Path == "" { + options.Path = defaultStreamableHTTPPath + } + + fs := flag.NewFlagSet("gonavi-mcp-server remote-config", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.StringVar(&options.Client, "client", options.Client, "remote MCP client name, for example openclaw or hermans") + fs.StringVar(&options.URL, "url", options.URL, "public Streamable HTTP MCP URL") + fs.StringVar(&options.Token, "token", options.Token, "bearer token used by the remote MCP client") + fs.StringVar(&options.ServerID, "server-id", options.ServerID, "MCP server id in generated config") + fs.StringVar(&options.LocalAddr, "addr", options.LocalAddr, "local HTTP listen address for GoNavi") + fs.StringVar(&options.Path, "path", options.Path, "local and public MCP path") + fs.StringVar(&options.GoNaviCommand, "gonavi-command", options.GoNaviCommand, "GoNavi application command on Windows") + fs.StringVar(&options.StandaloneCommand, "standalone-command", options.StandaloneCommand, "standalone gonavi-mcp-server command") + if err := fs.Parse(args); err != nil { + return RemoteMCPClientConfigOptions{}, err + } + if fs.NArg() > 0 { + return RemoteMCPClientConfigOptions{}, fmt.Errorf("未知 remote-config 参数: %s", strings.Join(fs.Args(), " ")) + } + return normalizeRemoteMCPClientConfigOptions(options), nil +} + +func normalizeRemoteMCPClientConfigOptions(options RemoteMCPClientConfigOptions) RemoteMCPClientConfigOptions { + options.Client = strings.ToLower(strings.TrimSpace(options.Client)) + if options.Client == "" { + options.Client = "remote-agent" + } + options.DisplayName = remoteMCPClientDisplayName(options.Client, options.DisplayName) + options.URL = strings.TrimSpace(options.URL) + if options.URL == "" { + options.URL = defaultRemoteMCPPublicURL + } + options.Token = strings.TrimSpace(options.Token) + if options.Token == "" { + options.Token = defaultRemoteMCPTokenHint + } + options.ServerID = strings.TrimSpace(options.ServerID) + if options.ServerID == "" { + options.ServerID = defaultRemoteMCPServerID + } + options.LocalAddr = strings.TrimSpace(options.LocalAddr) + if options.LocalAddr == "" { + options.LocalAddr = defaultStreamableHTTPAddr + } + options.Path = strings.TrimSpace(options.Path) + if options.Path == "" { + options.Path = defaultStreamableHTTPPath + } + if !strings.HasPrefix(options.Path, "/") { + options.Path = "/" + options.Path + } + options.GoNaviCommand = strings.TrimSpace(options.GoNaviCommand) + if options.GoNaviCommand == "" { + options.GoNaviCommand = "GoNavi.exe" + } + options.StandaloneCommand = strings.TrimSpace(options.StandaloneCommand) + if options.StandaloneCommand == "" { + options.StandaloneCommand = "gonavi-mcp-server" + } + return options +} + +func remoteMCPClientDisplayName(client string, fallback string) string { + if trimmed := strings.TrimSpace(fallback); trimmed != "" { + return trimmed + } + switch strings.ToLower(strings.TrimSpace(client)) { + case "openclaw": + return "OpenClaw" + case "hermans": + return "Hermans" + default: + return "远程 Agent" + } +} + +// RenderRemoteMCPClientConfig 生成给远程 Agent 和 Windows 本机分别使用的配置文本。 +func RenderRemoteMCPClientConfig(options RemoteMCPClientConfigOptions) (string, error) { + normalized := normalizeRemoteMCPClientConfigOptions(options) + config := map[string]any{ + "mcpServers": map[string]any{ + normalized.ServerID: map[string]any{ + "type": "streamable-http", + "url": normalized.URL, + "headers": map[string]string{ + "Authorization": "Bearer " + normalized.Token, + }, + }, + }, + } + configJSON, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", fmt.Errorf("生成远程 MCP 配置失败: %w", err) + } + + launch := remoteMCPHTTPLaunchCommand(normalized.GoNaviCommand, true, normalized.LocalAddr, normalized.Path, normalized.Token) + standalone := remoteMCPHTTPLaunchCommand(normalized.StandaloneCommand, false, normalized.LocalAddr, normalized.Path, normalized.Token) + lines := []string{ + fmt.Sprintf("GoNavi MCP 远程接入配置 - %s", normalized.DisplayName), + "", + "云端 Agent 配置(不要写数据库账号密码):", + string(configJSON), + "", + "Windows 本机启动 GoNavi MCP HTTP:", + launch, + "", + "独立 MCP Server 启动方式:", + standalone, + "", + "验证顺序:", + fmt.Sprintf("1. Windows 本机访问 http://%s/healthz,确认返回 ok。", normalized.LocalAddr), + fmt.Sprintf("2. %s 中配置上面的 Streamable HTTP MCP,URL 指向公网/隧道后的 %s。", normalized.DisplayName, normalized.URL), + "3. 先调用 get_connections 获取 connectionId,再调用 get_databases / get_tables / get_columns / get_table_ddl。", + "", + "安全边界:", + "- 数据库连接、账号和密码继续保存在 Windows GoNavi。", + "- 云端 Agent 只保存 MCP URL 和 Bearer Token。", + "- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。", + } + return strings.Join(lines, "\n") + "\n", nil +} + +// WriteRemoteMCPClientConfig 把远程 MCP 配置写入指定输出,供 CLI 模式复用。 +func WriteRemoteMCPClientConfig(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + options, err := ParseRemoteMCPClientConfigOptions(args) + if err != nil { + return err + } + text, err := RenderRemoteMCPClientConfig(options) + if err != nil { + return err + } + _, err = io.WriteString(w, text) + return err +} + +func remoteMCPHTTPLaunchCommand(command string, appSubcommand bool, addr string, path string, token string) string { + parts := []string{ + command, + } + if appSubcommand { + parts = append(parts, "mcp-server") + } + parts = append(parts, "http", "--addr", addr, "--path", path, "--token", token) + for index, part := range parts { + parts[index] = quoteCommandPart(part) + } + return strings.Join(parts, " ") +} + +func quoteCommandPart(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return `""` + } + if !strings.ContainsAny(trimmed, " \t\"") { + return trimmed + } + return `"` + strings.ReplaceAll(trimmed, `"`, `\"`) + `"` +} diff --git a/internal/mcpserver/remote_config_test.go b/internal/mcpserver/remote_config_test.go new file mode 100644 index 0000000..8ac60f7 --- /dev/null +++ b/internal/mcpserver/remote_config_test.go @@ -0,0 +1,83 @@ +package mcpserver + +import ( + "bytes" + "strings" + "testing" +) + +func TestParseRemoteMCPClientConfigOptionsUsesEnvAndFlags(t *testing.T) { + t.Setenv("GONAVI_MCP_PUBLIC_URL", "https://agent.example.com/mcp") + t.Setenv("GONAVI_MCP_HTTP_TOKEN", "env-token") + t.Setenv("GONAVI_MCP_HTTP_ADDR", "127.0.0.1:9100") + t.Setenv("GONAVI_MCP_HTTP_PATH", "/env-mcp") + + options, err := ParseRemoteMCPClientConfigOptions([]string{ + "--client", "hermans", + "--path", "mcp", + "--token", "flag-token", + }) + if err != nil { + t.Fatalf("ParseRemoteMCPClientConfigOptions returned error: %v", err) + } + if options.DisplayName != "Hermans" { + t.Fatalf("expected Hermans display name, got %q", options.DisplayName) + } + if options.URL != "https://agent.example.com/mcp" { + t.Fatalf("expected env url, got %q", options.URL) + } + if options.Token != "flag-token" { + t.Fatalf("expected flag token, got %q", options.Token) + } + if options.Path != "/mcp" { + t.Fatalf("expected normalized path, got %q", options.Path) + } +} + +func TestRenderRemoteMCPClientConfigShowsCloudAndWindowsCommands(t *testing.T) { + text, err := RenderRemoteMCPClientConfig(RemoteMCPClientConfigOptions{ + Client: "openclaw", + URL: "https://openclaw.example.com/mcp", + Token: "secret-token", + LocalAddr: "127.0.0.1:8765", + Path: "/mcp", + GoNaviCommand: `C:\Program Files\GoNavi\GoNavi.exe`, + StandaloneCommand: "gonavi-mcp-server", + }) + if err != nil { + t.Fatalf("RenderRemoteMCPClientConfig returned error: %v", err) + } + + for _, want := range []string{ + "GoNavi MCP 远程接入配置 - OpenClaw", + `"type": "streamable-http"`, + `"url": "https://openclaw.example.com/mcp"`, + `"Authorization": "Bearer secret-token"`, + `"C:\Program Files\GoNavi\GoNavi.exe" mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token`, + `gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token secret-token`, + "数据库连接、账号和密码继续保存在 Windows GoNavi", + "allowMutating=true", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected rendered config to contain %q, got:\n%s", want, text) + } + } + if strings.Contains(text, "gonavi-mcp-server mcp-server http") { + t.Fatalf("standalone command must not include app-only mcp-server subcommand, got:\n%s", text) + } +} + +func TestWriteRemoteMCPClientConfigWritesRenderedText(t *testing.T) { + var buffer bytes.Buffer + err := WriteRemoteMCPClientConfig(&buffer, []string{ + "--client", "openclaw", + "--url", "https://example.com/mcp", + "--token", "token-1", + }) + if err != nil { + t.Fatalf("WriteRemoteMCPClientConfig returned error: %v", err) + } + if !strings.Contains(buffer.String(), "https://example.com/mcp") { + t.Fatalf("expected written config to contain public url, got %s", buffer.String()) + } +} diff --git a/internal/mcpserver/service.go b/internal/mcpserver/service.go index e793ce6..cffef30 100644 --- a/internal/mcpserver/service.go +++ b/internal/mcpserver/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strings" "GoNavi-Wails/internal/ai" @@ -15,6 +16,7 @@ import ( const ( defaultMaxRowsPerResult = 200 maxRowsPerResultLimit = 1000 + redactedOpaqueTarget = "opaque-connection-string-configured" ) type Service struct { @@ -534,14 +536,49 @@ func describeConnectionTarget(config connection.ConnectionConfig) string { return host } if uri := strings.TrimSpace(config.URI); uri != "" { - return uri + return redactConnectionTarget(uri) } if dsn := strings.TrimSpace(config.DSN); dsn != "" { - return dsn + return redactConnectionTarget(dsn) } return strings.TrimSpace(config.Database) } +func redactConnectionTarget(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + + if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.User = nil + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() + } + + if looksLikeOpaqueConnectionString(trimmed) { + return redactedOpaqueTarget + } + return trimmed +} + +func looksLikeOpaqueConnectionString(value string) bool { + lower := strings.ToLower(strings.TrimSpace(value)) + if lower == "" { + return false + } + return strings.Contains(lower, "@") || + strings.Contains(lower, "password=") || + strings.Contains(lower, "pwd=") || + strings.Contains(lower, "user=") || + strings.Contains(lower, "uid=") || + strings.Contains(lower, "token=") || + strings.Contains(lower, "secret=") || + strings.Contains(lower, "access_key=") || + strings.Contains(lower, "api_key=") +} + func decodeNamedStringSlice(data interface{}, keys ...string) ([]string, error) { switch items := data.(type) { case nil: diff --git a/internal/mcpserver/service_test.go b/internal/mcpserver/service_test.go index 3bd6048..965d6ee 100644 --- a/internal/mcpserver/service_test.go +++ b/internal/mcpserver/service_test.go @@ -134,6 +134,50 @@ func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) { } } +func TestGetConnectionsRedactsOpaqueURIAndDSNTargets(t *testing.T) { + backend := &fakeBackend{ + savedConnections: []connection.SavedConnectionView{ + { + ID: "pg-uri", + Name: "Postgres URI", + Config: connection.ConnectionConfig{ + Type: "postgres", + URI: "postgres://postgres:secret@db.local:5432/app?sslmode=disable", + }, + }, + { + ID: "mysql-dsn", + Name: "MySQL DSN", + Config: connection.ConnectionConfig{ + Type: "mysql", + DSN: "root:secret@tcp(db.local:3306)/app?charset=utf8mb4", + }, + }, + }, + } + + service := NewService(backend) + result, out, err := service.GetConnections(context.Background(), nil, emptyArgs{}) + if err != nil { + t.Fatalf("GetConnections returned error: %v", err) + } + if result == nil || result.IsError { + t.Fatalf("expected success result, got %#v", result) + } + if len(out.Connections) != 2 { + t.Fatalf("expected 2 connections, got %d", len(out.Connections)) + } + if out.Connections[0].Target != "postgres://db.local:5432/app" { + t.Fatalf("expected URI target to remove credentials and query, got %q", out.Connections[0].Target) + } + if strings.Contains(out.Connections[0].Target, "secret") || strings.Contains(out.Connections[0].Target, "postgres@") { + t.Fatalf("URI target leaked credentials: %q", out.Connections[0].Target) + } + if out.Connections[1].Target != redactedOpaqueTarget { + t.Fatalf("expected opaque DSN target to be redacted, got %q", out.Connections[1].Target) + } +} + func TestGetAllColumnsReturnsCrossTableColumnSummaries(t *testing.T) { backend := &fakeBackend{ editableConnection: connection.SavedConnectionView{ diff --git a/main.go b/main.go index bc9e759..9fb3204 100644 --- a/main.go +++ b/main.go @@ -101,8 +101,10 @@ func runMCPServerMode(ctx context.Context, args []string) error { } logger.Infof("GoNavi MCP Streamable HTTP Server 启动:addr=%s path=%s", options.Addr, options.Path) return mcpserver.RunAppStreamableHTTPServer(ctx, options) + case "remote-config", "--remote-config": + return mcpserver.WriteRemoteMCPClientConfig(os.Stdout, args[1:]) default: - return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http)", args[0]) + return fmt.Errorf("未知 MCP server 模式: %s(支持 stdio/http/remote-config)", args[0]) } } diff --git a/main_test.go b/main_test.go index 56d918f..4b1d8f3 100644 --- a/main_test.go +++ b/main_test.go @@ -35,6 +35,7 @@ func TestShouldRunMCPServerMode(t *testing.T) { {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: "mcp-server remote config", args: []string{"mcp-server", "remote-config"}, want: true}, {name: "unknown", args: []string{"serve"}, want: false}, }