feat(ai): 增强 MCP 远程接入与上下文诊断

This commit is contained in:
Syngnat
2026-06-11 07:29:04 +08:00
parent d3278bb4c4
commit 26fb650e04
25 changed files with 923 additions and 8 deletions

View File

@@ -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

View File

@@ -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
```
---
## 贡献指南

View File

@@ -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”。

View File

@@ -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])
}
}

View File

@@ -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 历史对话');

View File

@@ -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('&quot;type&quot;: &quot;streamable-http&quot;');
expect(markup).toContain('&quot;url&quot;: &quot;https://&lt;你的域名或隧道地址&gt;/mcp&quot;');
expect(markup).toContain('&quot;Authorization&quot;: &quot;Bearer &lt;随机token&gt;&quot;');
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://&lt;你的域名或隧道地址&gt;/mcp --token &lt;随机token&gt;');
expect(markup).toContain('Windows 启动 GoNavi MCP HTTP');
expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt;');
expect(markup).toContain('独立二进制gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt;');

View File

@@ -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',

View 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 结果消息');
});
});

View 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,
};
};

View File

@@ -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', {

View File

@@ -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: '读取快捷键配置失败',

View File

@@ -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');

View File

@@ -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,

View File

@@ -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: '读取当前快捷键配置',

View File

@@ -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: "🧩",

View File

@@ -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',

View File

@@ -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);

View File

@@ -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');

View File

@@ -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: [

View 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 MCPURL 指向公网/隧道后的 %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, `"`, `\"`) + `"`
}

View 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())
}
}

View File

@@ -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:

View File

@@ -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{

View File

@@ -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])
}
}

View File

@@ -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},
}