mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-16 03:29:55 +08:00
✨ feat(ai): 增强 MCP 远程接入与上下文诊断
This commit is contained in:
10
README.md
10
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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
@@ -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”。
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 历史对话');
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -310,7 +310,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
|
||||
{remoteQuickStart.displayName} 远程 MCP 快速配置
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
下面两段分别给云端 Agent 和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。
|
||||
下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码。
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||
<div
|
||||
@@ -338,6 +338,34 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
|
||||
{remoteQuickStart.configJson}
|
||||
</code>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: darkMode ? 'rgba(15,23,42,0.55)' : 'rgba(255,255,255,0.78)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 12, color: overlayTheme.titleText }}>
|
||||
无 GUI / CLI 生成配置
|
||||
</div>
|
||||
<code
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 8,
|
||||
fontFamily: 'var(--gn-font-mono)',
|
||||
fontSize: 11,
|
||||
color: overlayTheme.titleText,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{remoteQuickStart.configCommand}
|
||||
</code>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
|
||||
用于生成可粘贴到 {remoteQuickStart.displayName} 的远程 MCP 配置,不会读取或输出数据库密码。
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
|
||||
91
frontend/src/components/ai/aiContextBudgetInsights.test.ts
Normal file
91
frontend/src/components/ai/aiContextBudgetInsights.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights';
|
||||
|
||||
describe('aiContextBudgetInsights', () => {
|
||||
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 结果消息');
|
||||
});
|
||||
});
|
||||
246
frontend/src/components/ai/aiContextBudgetInsights.ts
Normal file
246
frontend/src/components/ai/aiContextBudgetInsights.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIContextItem,
|
||||
AIMCPToolDescriptor,
|
||||
AISkillConfig,
|
||||
AIUserPromptSettings,
|
||||
} from '../../types';
|
||||
|
||||
type ContextRiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
interface BuildAIContextBudgetSnapshotOptions {
|
||||
aiContexts?: Record<string, AIContextItem[]>;
|
||||
aiChatHistory?: Record<string, AIChatMessage[]>;
|
||||
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<Record<string, number>>((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<string>();
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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', {
|
||||
|
||||
@@ -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: '读取快捷键配置失败',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
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: '读取当前快捷键配置',
|
||||
|
||||
@@ -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: "🧩",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<AIMCPClientInstallStatus, 'displayName' | 'message'> | null,
|
||||
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName' | 'message'>> | 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<AIMCPClientInstallStatus, 'displayName'> | null,
|
||||
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName'>> | 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: [
|
||||
|
||||
215
internal/mcpserver/remote_config.go
Normal file
215
internal/mcpserver/remote_config.go
Normal file
@@ -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, `"`, `\"`) + `"`
|
||||
}
|
||||
83
internal/mcpserver/remote_config_test.go
Normal file
83
internal/mcpserver/remote_config_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
4
main.go
4
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user