feat(ai): 增加 MCP HTTP 服务与 Docker 配置诊断

- AI 设置页新增 GoNavi MCP HTTP 服务开关与状态展示
- 后端新增 HTTP MCP 子进程生命周期管理和鉴权配置
- 增加 Docker MCP 配置诊断工具与参数提示校验
This commit is contained in:
Syngnat
2026-06-11 18:27:13 +08:00
parent b7e50118f0
commit 5d4989f68f
28 changed files with 1546 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Form, message as antdMessage } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig } from '../types';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
import {
resolvePresetBaseURL,
resolvePresetModelSelection,
@@ -39,6 +39,15 @@ interface AISettingsModalProps {
focusProviderId?: string;
}
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
running: false,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
message: 'GoNavi MCP HTTP 服务未启动',
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
@@ -46,6 +55,8 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
@@ -142,7 +153,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
return fallback;
}
};
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, skillsRes, mcpClientStatusesRes] = await Promise.all([
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, mcpHTTPServerStatusRes, skillsRes, mcpClientStatusesRes] = await Promise.all([
callOrFallback(() => Service.AIGetProviders?.(), []),
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
@@ -150,6 +161,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
callOrFallback(() => Service.AIGetMCPServers?.(), []),
callOrFallback(() => Service.AIListMCPTools?.(), []),
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS),
callOrFallback(() => Service.AIGetSkills?.(), []),
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
]);
@@ -169,6 +181,12 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
if (mcpHTTPServerStatusRes) {
setMCPHTTPServerStatus({
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...mcpHTTPServerStatusRes,
});
}
if (Array.isArray(skillsRes)) setSkills(skillsRes);
if (Array.isArray(mcpClientStatusesRes)) {
syncMCPClientStatuses(mcpClientStatusesRes);
@@ -447,6 +465,58 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
};
const handleToggleMCPHTTPServer = async (checked: boolean) => {
try {
setMCPHTTPServerLoading(true);
const Service = await resolveAIService();
if (!Service) {
throw new Error('当前运行时暂不支持 MCP HTTP 服务控制');
}
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
throw new Error('当前版本暂不支持启动 MCP HTTP 服务');
}
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
throw new Error('当前版本暂不支持停止 MCP HTTP 服务');
}
const nextStatus = checked
? await Service.AIStartMCPHTTPServer({
addr: mcpHTTPServerStatus.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: mcpHTTPServerStatus.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
schemaOnly: true,
})
: await Service.AIStopMCPHTTPServer();
if (nextStatus) {
setMCPHTTPServerStatus({
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...nextStatus,
});
}
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
} catch (e: any) {
void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败');
} finally {
setMCPHTTPServerLoading(false);
}
};
const handleCopyMCPHTTPServerURL = async () => {
const url = String(mcpHTTPServerStatus.url || '').trim();
if (!url) {
void messageApi.error('当前没有可复制的 MCP HTTP URL');
return;
}
await copyTextToClipboard(url, 'MCP HTTP URL 已复制');
};
const handleCopyMCPHTTPServerAuthorization = async () => {
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
if (!authorizationHeader) {
void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header');
return;
}
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制');
};
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
};
@@ -653,6 +723,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
selectedMCPClient={selectedMCPClient}
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpHTTPServerStatus={mcpHTTPServerStatus}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
@@ -662,6 +733,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
mcpHTTPServerLoading={mcpHTTPServerLoading}
onToggleHTTPServer={handleToggleMCPHTTPServer}
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
onSelectClient={handleSelectMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}

View File

@@ -53,6 +53,9 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_mcp_authoring_guide');
expect(markup).toContain('inspect_mcp_draft');
expect(markup).toContain('真实校验器试算');
expect(markup).toContain('排查 Docker MCP 启动');
expect(markup).toContain('inspect_mcp_docker_setup');
expect(markup).toContain('docker run 参数是否拆对');
expect(markup).toContain('查看 MCP 工具参数');
expect(markup).toContain('inspect_mcp_tool_schema');
expect(markup).toContain('inputSchema');

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
describe('AIMCPHTTPServerPanel', () => {
it('renders the in-app MCP HTTP switch and remote connection details', () => {
const markup = renderToStaticMarkup(
<AIMCPHTTPServerPanel
status={{
running: true,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
authorizationHeader: 'Bearer gnv_test',
message: 'GoNavi MCP HTTP 服务已启动',
}}
loading={false}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
onToggle={() => {}}
onCopyURL={() => {}}
onCopyAuthorization={() => {}}
/>,
);
expect(markup).toContain('GoNavi MCP HTTP 服务');
expect(markup).toContain('已启动');
expect(markup).toContain('schema-only');
expect(markup).toContain('http://127.0.0.1:8765/mcp');
expect(markup).toContain('复制 Authorization');
});
});

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Button, Switch, Tag } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import type { AIMCPHTTPServerStatus } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
export interface AIMCPHTTPServerPanelProps {
status: AIMCPHTTPServerStatus;
loading: boolean;
cardBg: string;
cardBorder: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onToggle: (checked: boolean) => void;
onCopyURL: () => void;
onCopyAuthorization: () => void;
}
const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
status,
loading,
cardBg,
cardBorder,
darkMode,
overlayTheme,
onToggle,
onCopyURL,
onCopyAuthorization,
}) => {
const running = status?.running === true;
const url = String(status?.url || '').trim();
const authorizationHeader = String(status?.authorizationHeader || '').trim();
return (
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 800, fontSize: 14, color: overlayTheme.titleText }}>GoNavi MCP HTTP </div>
<Tag color={running ? 'success' : 'default'} style={{ marginInlineEnd: 0 }}>
{running ? '已启动' : '未启动'}
</Tag>
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
schema-only
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
OpenClawHermans Agent 使 Bearer Token DDL
</div>
</div>
<Switch
checked={running}
loading={loading}
onChange={onToggle}
checkedChildren="开"
unCheckedChildren="关"
/>
</div>
<div
style={{
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{running
? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。'
: '不用再手动执行 GoNavi.exe mcp-server http 命令;在这里打开开关即可启动本机 HTTP MCP。'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<code
style={{
fontSize: 12,
color: overlayTheme.titleText,
background: darkMode ? 'rgba(0,0,0,0.22)' : 'rgba(0,0,0,0.04)',
borderRadius: 8,
padding: '4px 7px',
}}
>
{url || 'http://127.0.0.1:8765/mcp'}
</code>
<Button size="small" icon={<CopyOutlined />} disabled={!running || !url} onClick={onCopyURL}>
URL
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!running || !authorizationHeader}
onClick={onCopyAuthorization}
>
Authorization
</Button>
</div>
</div>
</div>
);
};
export default AIMCPHTTPServerPanel;

View File

@@ -71,6 +71,14 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
selectedMCPClientCommandText: '',
mcpHTTPServerStatus: {
running: false,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
message: 'GoNavi MCP HTTP 服务未启动',
},
mcpServers: [],
mcpTools: [],
darkMode: false,
@@ -80,6 +88,10 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
inputBg: '#fff',
loading: false,
mcpClientStatusLoading: false,
mcpHTTPServerLoading: false,
onToggleHTTPServer: () => {},
onCopyHTTPServerURL: () => {},
onCopyHTTPServerAuthorization: () => {},
onSelectClient: () => {},
onRefreshStatus: () => {},
onCopyConfigPath: () => {},
@@ -99,6 +111,10 @@ describe('AISettingsMCPSection', () => {
<AISettingsMCPSection {...buildMCPSectionProps()} />,
);
expect(markup).toContain('GoNavi MCP HTTP 服务');
expect(markup).toContain('不用再手动执行 GoNavi.exe mcp-server http 命令');
expect(markup).toContain('http://127.0.0.1:8765/mcp');
expect(markup).toContain('复制 Authorization');
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('尚未把当前 GoNavi MCP 接入到这里');
expect(markup).toContain('新增 MCP 参数速查');
@@ -229,4 +245,20 @@ describe('AISettingsMCPSection', () => {
timeoutSeconds: 45,
}));
});
it('toggles the in-app MCP HTTP service from the switch panel', () => {
const onToggleHTTPServer = vi.fn();
const tree = AISettingsMCPSection(buildMCPSectionProps({
onToggleHTTPServer,
}));
const httpPanel = findElement(
tree,
(node) => node.props?.onToggle === onToggleHTTPServer,
);
expect(httpPanel).toBeTruthy();
httpPanel.props.onToggle(true);
expect(onToggleHTTPServer).toHaveBeenCalledWith(true);
});
});

View File

@@ -2,13 +2,14 @@ import React from 'react';
import { Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import type { AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import type { MCPClientKey } from '../../utils/mcpClientInstallStatus';
import { MCP_FIELD_GUIDES } from '../../utils/mcpServerGuidance';
import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import AIMCPFieldGuideCard from './AIMCPFieldGuideCard';
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
import AIMCPServerCard from './AIMCPServerCard';
export type { MCPClientKey } from '../../utils/mcpClientInstallStatus';
@@ -18,6 +19,7 @@ export interface AISettingsMCPSectionProps {
selectedMCPClient: MCPClientKey;
selectedMCPClientStatus?: AIMCPClientInstallStatus;
selectedMCPClientCommandText: string;
mcpHTTPServerStatus: AIMCPHTTPServerStatus;
mcpServers: AIMCPServerConfig[];
mcpTools: AIMCPToolDescriptor[];
darkMode: boolean;
@@ -27,6 +29,10 @@ export interface AISettingsMCPSectionProps {
inputBg: string;
loading: boolean;
mcpClientStatusLoading: boolean;
mcpHTTPServerLoading: boolean;
onToggleHTTPServer: (checked: boolean) => void;
onCopyHTTPServerURL: () => void;
onCopyHTTPServerAuthorization: () => void;
onSelectClient: (client: MCPClientKey) => void;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
@@ -44,6 +50,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
selectedMCPClient,
selectedMCPClientStatus,
selectedMCPClientCommandText,
mcpHTTPServerStatus,
mcpServers,
mcpTools,
darkMode,
@@ -53,6 +60,10 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
inputBg,
loading,
mcpClientStatusLoading,
mcpHTTPServerLoading,
onToggleHTTPServer,
onCopyHTTPServerURL,
onCopyHTTPServerAuthorization,
onSelectClient,
onRefreshStatus,
onCopyConfigPath,
@@ -65,6 +76,17 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
onDeleteServer,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<AIMCPHTTPServerPanel
status={mcpHTTPServerStatus}
loading={mcpHTTPServerLoading}
cardBg={cardBg}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
onToggle={onToggleHTTPServer}
onCopyURL={onCopyHTTPServerURL}
onCopyAuthorization={onCopyHTTPServerAuthorization}
/>
<AIMCPClientInstallPanel
statuses={mcpClientStatuses}
selectedClient={selectedMCPClient}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';
import type { AIToolCall, SavedConnection } from '../../types';
import { executeLocalAIToolCall } from './aiLocalToolExecutor';
const buildConnection = (): SavedConnection => ({
id: 'conn-1',
name: '主库',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
},
});
const buildToolCall = (name: string, args: Record<string, unknown>): AIToolCall => ({
id: `call-${name}`,
type: 'function',
function: {
name,
arguments: JSON.stringify(args),
},
});
describe('aiLocalToolExecutor inspect_mcp_docker_setup', () => {
it('returns docker mcp configuration issues through the unified local tool executor', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_mcp_docker_setup', {}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
getMCPServers: vi.fn().mockResolvedValue([
{
id: 'docker-broken',
name: 'Docker Broken',
transport: 'stdio',
command: 'docker',
args: ['run', '--rm'],
env: {},
enabled: true,
timeoutSeconds: 10,
},
]),
},
});
if (!result.success) {
throw new Error(result.content);
}
expect(result.success).toBe(true);
expect(result.toolName).toBe('inspect_mcp_docker_setup');
expect(result.content).toContain('"dockerServerCount":1');
expect(result.content).toContain('"docker-interactive-missing"');
expect(result.content).toContain('Docker 首次拉起可能较慢');
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { buildMCPDockerSetupSnapshot } from './aiMCPDockerInsights';
describe('aiMCPDockerInsights', () => {
it('summarizes docker mcp servers and flags missing stdio-critical args', () => {
const snapshot = buildMCPDockerSetupSnapshot({
mcpServers: [
{
id: 'docker-broken',
name: 'Docker Broken',
transport: 'stdio',
command: 'docker',
args: ['--rm'],
env: {},
enabled: true,
timeoutSeconds: 10,
},
{
id: 'node-ok',
name: 'Node OK',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: {},
enabled: true,
timeoutSeconds: 20,
},
],
mcpTools: [],
});
expect(snapshot.dockerServerCount).toBe(1);
expect(snapshot.incompleteServerCount).toBe(1);
expect(snapshot.servers[0].docker.hasRun).toBe(false);
expect(snapshot.servers[0].docker.hasInteractive).toBe(false);
expect(snapshot.servers[0].docker.image).toBe('');
expect(snapshot.servers[0].nextActions.join('\n')).toContain('run');
expect(snapshot.servers[0].nextActions.join('\n')).toContain('-i');
expect(snapshot.warnings).toContain('有 1 个 Docker MCP 缺少 run、-i 或镜像名等关键参数');
});
it('handles complete docker run options without treating option values as images', () => {
const snapshot = buildMCPDockerSetupSnapshot({
mcpServers: [
{
id: 'docker-ok',
name: 'Docker OK',
transport: 'stdio',
command: 'C:\\Program Files\\Docker\\docker.exe',
args: [
'run',
'--rm',
'-i',
'-p',
'8080:8080',
'-v',
'C:\\workspace:/workspace',
'-e',
'API_KEY=***',
'ghcr.io/acme/mcp-server:latest',
],
env: { DOCKER_HOST: 'npipe:////./pipe/docker_engine' },
enabled: true,
timeoutSeconds: 45,
},
],
mcpTools: [
{
alias: 'docker_probe',
originalName: 'probe',
serverId: 'docker-ok',
serverName: 'Docker OK',
},
],
});
expect(snapshot.incompleteServerCount).toBe(0);
expect(snapshot.servers[0].docker).toMatchObject({
hasRun: true,
hasInteractive: true,
hasRm: true,
image: 'ghcr.io/acme/mcp-server:latest',
});
expect(snapshot.servers[0].envKeys).toEqual(['DOCKER_HOST']);
expect(snapshot.servers[0].discoveredToolCount).toBe(1);
expect(snapshot.servers[0].nextActions).toEqual([]);
});
});

View File

@@ -0,0 +1,201 @@
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance';
import { validateMCPServerDraft } from '../../utils/mcpServerValidation';
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeCommandName = (command: unknown): string => {
const raw = toTrimmedString(command);
const lastPathPart = raw.split(/[\\/]/u).pop() || raw;
return lastPathPart
.replace(/\.(exe|cmd|bat|ps1)$/iu, '')
.toLowerCase();
};
const normalizeArgs = (args: unknown): string[] =>
(Array.isArray(args) ? args : [])
.map(toTrimmedString)
.filter(Boolean);
const isDockerServer = (server: AIMCPServerConfig): boolean =>
normalizeCommandName(server.command) === 'docker';
const hasArg = (args: string[], expected: string): boolean =>
args.some((arg) => arg.toLowerCase() === expected.toLowerCase());
const findDockerImage = (args: string[]): string => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
for (let index = 0; index < candidates.length; index += 1) {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;
}
return arg;
}
return '';
};
const getServerToolCount = (serverId: string, tools: AIMCPToolDescriptor[]): number =>
tools.filter((tool) => tool.serverId === serverId).length;
const buildDockerNextActions = (params: {
enabled: boolean;
hasRun: boolean;
hasInteractive: boolean;
image: string;
timeoutSeconds: number;
issueKeys: Set<string>;
discoveredToolCount: number;
}): string[] => {
const actions: string[] = [];
if (!params.hasRun) {
actions.push('在 args 中补充 run例如 docker run --rm -i <image>');
}
if (!params.hasInteractive) {
actions.push('在 args 中补充 -i 或 --interactive确保 MCP stdio 不会立即断开');
}
if (!params.image) {
actions.push('在 docker run 选项之后补充 README 提供的镜像名');
}
if (params.timeoutSeconds < 20 || params.issueKeys.has('timeout-out-of-range')) {
actions.push('Docker 首次拉起可能较慢,建议 timeoutSeconds 使用 45 或 60');
}
if (params.enabled && params.discoveredToolCount === 0 && actions.length === 0) {
actions.push('配置结构看起来完整但未发现工具,建议点击“测试工具发现”确认 Docker、镜像和容器内依赖可用');
}
if (!params.enabled) {
actions.push('该 Docker MCP 当前未启用;确认配置后再启用并测试工具发现');
}
return actions;
};
export const buildMCPDockerSetupSnapshot = (params: {
mcpServers?: AIMCPServerConfig[];
mcpTools?: AIMCPToolDescriptor[];
includeDisabled?: boolean;
serverId?: string;
}) => {
const {
mcpServers = [],
mcpTools = [],
includeDisabled = true,
serverId = '',
} = params;
const dockerServers = (Array.isArray(mcpServers) ? mcpServers : [])
.filter(isDockerServer)
.filter((server) => includeDisabled || server.enabled !== false)
.filter((server) => !toTrimmedString(serverId) || server.id === toTrimmedString(serverId))
.map((server) => {
const args = normalizeArgs(server.args);
const validation = validateMCPServerDraft(server);
const issueKeys = new Set(validation.issues.map((issue) => issue.key));
const discoveredToolCount = getServerToolCount(server.id, mcpTools);
const hasRun = hasArg(args, 'run');
const hasInteractive = hasArg(args, '-i') || hasArg(args, '--interactive');
const hasRm = hasArg(args, '--rm');
const image = findDockerImage(args);
const enabled = server.enabled !== false;
const timeoutSeconds = Number(server.timeoutSeconds) || 20;
return {
id: server.id,
name: server.name,
enabled,
command: server.command,
args,
timeoutSeconds,
launchCommandPreview: buildMCPLaunchPreview(server.command, args),
envKeys: Object.keys(server.env || {}).sort(),
envVarCount: Object.keys(server.env || {}).length,
discoveredToolCount,
docker: {
hasRun,
hasInteractive,
hasRm,
image,
imageLooksPlaceholder: /^(image|your-image|mcp\/server-fetch:latest)$/iu.test(image),
},
validation: {
errorCount: validation.errorCount,
warningCount: validation.warningCount,
canTest: validation.canTest,
canSave: validation.canSave,
issues: validation.issues,
},
nextActions: buildDockerNextActions({
enabled,
hasRun,
hasInteractive,
image,
timeoutSeconds,
issueKeys,
discoveredToolCount,
}),
};
})
.sort((left, right) => String(left.name || '').localeCompare(String(right.name || '')));
const enabledDockerServerCount = dockerServers.filter((server) => server.enabled).length;
const incompleteServerCount = dockerServers.filter((server) =>
!server.docker.hasRun || !server.docker.hasInteractive || !server.docker.image,
).length;
const warningServerCount = dockerServers.filter((server) => server.validation.warningCount > 0).length;
const serversWithoutDiscoveredTools = dockerServers.filter((server) => server.enabled && server.discoveredToolCount === 0).length;
const warnings: string[] = [];
const nextActions: string[] = [];
if (dockerServers.length === 0) {
nextActions.push('如果 README 提供的是 docker run -i --rm <image>,可在 MCP 设置中选择“Docker 镜像”模板新建服务');
}
if (incompleteServerCount > 0) {
warnings.push(`${incompleteServerCount} 个 Docker MCP 缺少 run、-i 或镜像名等关键参数`);
nextActions.push('先修复 Docker MCP 关键参数,再重新测试工具发现');
} else if (warningServerCount > 0) {
warnings.push(`${warningServerCount} 个 Docker MCP 仍存在配置告警`);
nextActions.push('打开对应 Docker MCP 服务,按配置检查提示确认参数和超时时间');
}
if (serversWithoutDiscoveredTools > 0) {
warnings.push(`${serversWithoutDiscoveredTools} 个已启用 Docker MCP 暂未发现工具`);
nextActions.push('确认本机 Docker 可用、镜像已拉取,并点击“测试工具发现”刷新工具列表');
}
return {
dockerServerCount: dockerServers.length,
enabledDockerServerCount,
disabledDockerServerCount: dockerServers.length - enabledDockerServerCount,
incompleteServerCount,
warningServerCount,
serversWithoutDiscoveredTools,
servers: dockerServers,
warnings,
nextActions,
message: dockerServers.length > 0
? incompleteServerCount > 0
? `当前有 ${dockerServers.length} 个 Docker MCP其中 ${incompleteServerCount} 个关键参数不完整`
: `当前有 ${dockerServers.length} 个 Docker MCP其中 ${enabledDockerServerCount} 个已启用`
: '当前没有 Docker MCP 服务',
};
};

View File

@@ -47,4 +47,22 @@ describe('aiMCPDraftInspectionInsights', () => {
expect(snapshot.nextActions.join('\n')).toContain('把整行命令放到完整命令框自动拆分');
expect(snapshot.nextActions.join('\n')).toContain('环境变量改成每行 KEY=VALUE');
});
it('applies the docker template and explains docker-specific missing args', () => {
const snapshot = buildMCPDraftInspectionSnapshot({
templateKey: 'docker',
args: ['run', '--rm'],
timeoutSeconds: 10,
});
expect(snapshot.draft.command).toBe('docker');
expect(snapshot.draft.recommendedTemplate).toMatchObject({
key: 'docker',
title: 'Docker 镜像',
});
expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-interactive-missing');
expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-image-missing');
expect(snapshot.nextActions.join('\n')).toContain('Docker MCP 的 args 里补 -i');
expect(snapshot.nextActions.join('\n')).toContain('Docker MCP 的 args 里补 README 提供的镜像名');
});
});

View File

@@ -79,7 +79,16 @@ const buildNextActions = (params: {
actions.push('把整行命令放到完整命令框自动拆分command 只保留可执行程序,脚本名、包名和 --stdio 放到 args。');
}
if (issueKeys.has('args-missing-for-launcher')) {
actions.push('给启动器补齐参数npx 通常需要 -y 和包名node 需要 server.jspython 需要 -m 模块名uvx 需要包名。');
actions.push('给启动器补齐参数npx 通常需要 -y 和包名node 需要 server.jspython 需要 -m 模块名uvx 需要包名docker 需要 run、-i 和镜像名。');
}
if (issueKeys.has('docker-run-missing')) {
actions.push('Docker MCP 的 command 填 dockerargs 里单独补 run。');
}
if (issueKeys.has('docker-interactive-missing')) {
actions.push('Docker MCP 的 args 里补 -i 或 --interactive避免 stdio 连接立即断开。');
}
if (issueKeys.has('docker-image-missing')) {
actions.push('Docker MCP 的 args 里补 README 提供的镜像名,例如 mcp/server-fetch:latest。');
}
if (issueKeys.has('args-contain-env-or-shell-glue') || issueKeys.has('env-invalid-lines')) {
actions.push('环境变量改成每行 KEY=VALUE不要把 export、set、env、&& 或 $env:KEY=VALUE; 放进 args。');

View File

@@ -15,6 +15,7 @@ import { buildAISafetySnapshot } from './aiSafetyInsights';
import { buildAIToolCatalogSnapshot } from './aiToolCatalogInsights';
import { buildMCPAuthoringGuideSnapshot } from './aiMCPAuthoringGuideInsights';
import { buildMCPDraftInspectionSnapshot } from './aiMCPDraftInspectionInsights';
import { buildMCPDockerSetupSnapshot } from './aiMCPDockerInsights';
import { buildAISetupHealthSnapshot } from './aiSetupHealthInsights';
import { buildMCPSetupSnapshot } from './aiMCPInsights';
import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights';
@@ -57,6 +58,11 @@ const loadMCPSetupState = async (runtime: AISnapshotInspectionRuntime | undefine
: Promise.resolve(undefined),
]);
const loadMCPServers = async (runtime: AISnapshotInspectionRuntime | undefined) =>
typeof runtime?.getMCPServers === 'function'
? runtime.getMCPServers()
: undefined;
const loadMCPClientInstallStatuses = async (runtime: AISnapshotInspectionRuntime | undefined) =>
typeof runtime?.getMCPClientInstallStatuses === 'function'
? runtime.getMCPClientInstallStatuses()
@@ -210,6 +216,18 @@ export async function executeAIConfigSnapshotToolCall(
content: JSON.stringify(buildMCPDraftInspectionSnapshot(args)),
success: true,
};
case 'inspect_mcp_docker_setup': {
const mcpServers = await loadMCPServers(runtime);
return {
content: JSON.stringify(buildMCPDockerSetupSnapshot({
mcpServers: Array.isArray(mcpServers) ? mcpServers : [],
mcpTools,
serverId: args.serverId,
includeDisabled: args.includeDisabled !== false,
})),
success: true,
};
}
case 'inspect_mcp_tool_schema':
return {
content: JSON.stringify(buildMCPToolSchemaSnapshot({
@@ -245,6 +263,7 @@ export async function executeAIConfigSnapshotToolCall(
inspect_mcp_remote_access: '读取 MCP 远程接入指引失败',
inspect_mcp_authoring_guide: '读取 MCP 新增填写指引失败',
inspect_mcp_draft: '校验 MCP 新增草稿失败',
inspect_mcp_docker_setup: '检查 Docker MCP 配置失败',
inspect_mcp_tool_schema: '读取 MCP 工具参数 schema 失败',
inspect_ai_guidance: '读取当前 AI 提示与技能配置失败',
}[toolName] || '读取 AI 配置探针失败';

View File

@@ -37,6 +37,14 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
jvmDiagnostic: '',
};
let mockMCPServers: any[] = [];
let mockMCPHTTPServerStatus: any = {
running: false,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
message: 'GoNavi MCP HTTP 服务未启动',
};
let mockMCPClientStatuses: any[] = [
{
client: 'claude-code',
@@ -365,6 +373,33 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
return null;
},
AIGetMCPClientInstallStatuses: async () => cloneBrowserMockValue(mockMCPClientStatuses),
AIGetMCPHTTPServerStatus: async () => cloneBrowserMockValue(mockMCPHTTPServerStatus),
AIStartMCPHTTPServer: async (input: any) => {
const addr = String(input?.addr || '127.0.0.1:8765');
const path = String(input?.path || '/mcp').startsWith('/') ? String(input?.path || '/mcp') : `/${String(input?.path || '/mcp')}`;
mockMCPHTTPServerStatus = {
running: true,
addr,
path,
url: `http://${addr}${path}`,
schemaOnly: true,
token: 'gnv_browser_mock_token',
authorizationHeader: 'Bearer gnv_browser_mock_token',
startedAt: Date.now(),
message: 'GoNavi MCP HTTP 服务已启动',
};
return cloneBrowserMockValue(mockMCPHTTPServerStatus);
},
AIStopMCPHTTPServer: async () => {
mockMCPHTTPServerStatus = {
...mockMCPHTTPServerStatus,
running: false,
token: '',
authorizationHeader: '',
message: 'GoNavi MCP HTTP 服务已停止',
};
return cloneBrowserMockValue(mockMCPHTTPServerStatus);
},
AIGetMCPServers: async () => cloneBrowserMockValue(mockMCPServers),
AIInstallClaudeCodeMCP: async () => {
mockMCPClientStatuses = mockMCPClientStatuses.map((item) => item.client === 'claude-code'

View File

@@ -609,6 +609,18 @@ export interface AIMCPClientInstallStatus {
args?: string[];
}
export interface AIMCPHTTPServerStatus {
running: boolean;
addr: string;
path: string;
url: string;
schemaOnly: boolean;
token?: string;
authorizationHeader?: string;
startedAt?: number;
message: string;
}
export type AISkillScope = "global" | "database" | "jvm" | "jvmDiagnostic";
export interface AISkillConfig {

View File

@@ -53,18 +53,41 @@ export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
icon: "🧭",
desc: "查看新增 MCP 的填写指引",
detail:
"返回新增 MCP 表单里各字段的作用、推荐填写顺序、完整命令自动拆分规则,以及 npx / Node / uvx / Python / EXE 模板样例。适合用户问“command/args/env 到底怎么填”“给我一个 npx / node / uvx / python 示例”“为什么启动命令不能整行填”时,先读这份真实接入指引。",
"返回新增 MCP 表单里各字段的作用、推荐填写顺序、完整命令自动拆分规则,以及 npx / Node / uvx / Python / Docker / EXE 模板样例。适合用户问“command/args/env 到底怎么填”“给我一个 npx / node / uvx / python / docker 示例”“为什么启动命令不能整行填”时,先读这份真实接入指引。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_mcp_authoring_guide",
description:
"读取 GoNavi 当前内置的 MCP 新增指引,包括推荐填写顺序、字段作用、常见命令示例、完整命令自动拆分规则,以及 npx / Node / uvx / Python / EXE 模板样例。适用于用户提到新增 MCP 不知道 command、args、env、timeout 怎么填,或想要一个最接近的模板时,先读取这份真实前端接入指南,不要凭记忆口述。",
"读取 GoNavi 当前内置的 MCP 新增指引,包括推荐填写顺序、字段作用、常见命令示例、完整命令自动拆分规则,以及 npx / Node / uvx / Python / Docker / EXE 模板样例。适用于用户提到新增 MCP 不知道 command、args、env、timeout 怎么填,或想要一个最接近的模板时,先读取这份真实前端接入指南,不要凭记忆口述。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_mcp_docker_setup",
icon: "🐳",
desc: "检查 Docker MCP 启动配置",
detail:
"读取当前已保存的 Docker MCP 服务,检查 command/args 是否正确拆成 docker、run、--rm、-i、镜像名和容器参数并返回缺失参数、已发现工具数、超时建议和下一步修复动作。适合用户按 Docker README 新增 MCP 后工具发现失败、容器一启动就退出、或不确定 docker run 参数该怎么填时调用。",
params: "serverId?, includeDisabled?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_mcp_docker_setup",
description:
"检查当前已保存 Docker MCP 服务的启动参数,返回 Docker MCP 服务列表、docker run/-i/镜像名/--rm/env/timeout 状态、工具发现数量、配置告警和 nextActions。适用于用户提到 Docker MCP、docker run、容器化 MCP、工具发现 0 个、容器 stdio 断开、或 AI 准备指导用户修复 Docker MCP 配置时,先读取真实配置快照。",
parameters: {
type: "object",
properties: {
serverId: { type: "string", description: "可选,只检查某个 MCP serverId不传则检查全部 Docker MCP" },
includeDisabled: { type: "boolean", description: "可选,是否包含已禁用 Docker MCP默认 true" },
},
},
},
},
},
{
name: "inspect_mcp_draft",
icon: "🧪",
@@ -92,7 +115,7 @@ export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
},
envText: { type: "string", description: "可选,环境变量草稿,每行 KEY=VALUE不要传 export、set 或 $env: 前缀" },
timeoutSeconds: { type: "number", description: "可选,单次工具发现或调用超时秒数;推荐 20慢启动服务可用 45 或 60" },
templateKey: { type: "string", enum: ["npx", "uvx", "node", "python", "exe"], description: "可选,先套用一个内置模板再覆盖用户传入字段" },
templateKey: { type: "string", enum: ["npx", "uvx", "node", "python", "docker", "exe"], description: "可选,先套用一个内置模板再覆盖用户传入字段" },
name: { type: "string", description: "可选MCP 服务名称,例如 GitHub、Filesystem、Browser" },
},
},

View File

@@ -99,6 +99,11 @@ export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [
steps: 'inspect_mcp_authoring_guide -> inspect_mcp_draft -> inspect_mcp_setup',
description: '适合先读真实字段说明、模板样例和整行命令拆分规则,再把用户贴出的命令或草稿交给真实校验器试算,最后结合当前 MCP 配置现状判断应该新增哪种启动方式。',
},
{
title: '排查 Docker MCP 启动',
steps: 'inspect_mcp_docker_setup -> inspect_mcp_draft -> inspect_mcp_setup',
description: '适合用户按 Docker README 新增 MCP 后发现 0 个工具、容器一启动就退出,或不确定 docker run 参数是否拆对时,先检查 run、-i、镜像名和超时设置。',
},
{
title: '查看 MCP 工具参数',
steps: 'inspect_mcp_setup -> inspect_mcp_tool_schema',

View File

@@ -60,6 +60,15 @@ describe('aiToolRegistry', () => {
expect(info?.desc).toContain('MCP 新增草稿');
expect(info?.tool.function.description).toContain('真实校验器试算');
expect(info?.tool.function.parameters?.properties?.fullCommand?.description).toContain('一整行 MCP 启动命令');
expect(info?.tool.function.parameters?.properties?.templateKey?.enum).toContain('docker');
});
it('registers the mcp-docker-setup inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_docker_setup');
expect(info).toBeTruthy();
expect(info?.desc).toContain('Docker MCP');
expect(info?.tool.function.description).toContain('docker run');
expect(info?.tool.function.parameters?.properties?.includeDisabled?.description).toContain('默认 true');
});
it('registers the mcp-tool-schema inspector as a builtin tool', () => {

View File

@@ -66,7 +66,24 @@ const hasDockerImageArg = (args: string[]): boolean => {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if (['-e', '--env', '--name', '--network', '-v', '--volume'].includes(lower)) {
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;

View File

@@ -94,7 +94,24 @@ const hasDockerImageArg = (args: string[]): boolean => {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if (['-e', '--env', '--name', '--network', '-v', '--volume'].includes(lower)) {
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;

View File

@@ -30,6 +30,8 @@ export function AIGetEditableProvider(arg1:string):Promise<ai.ProviderConfig>;
export function AIGetMCPClientInstallStatuses():Promise<Array<ai.MCPClientInstallStatus>>;
export function AIGetMCPHTTPServerStatus():Promise<ai.MCPHTTPServerStatus>;
export function AIGetMCPServers():Promise<Array<ai.MCPServerConfig>>;
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
@@ -68,6 +70,10 @@ export function AISetContextLevel(arg1:string):Promise<void>;
export function AISetSafetyLevel(arg1:string):Promise<void>;
export function AIStartMCPHTTPServer(arg1:ai.MCPHTTPServerOptions):Promise<ai.MCPHTTPServerStatus>;
export function AIStopMCPHTTPServer():Promise<ai.MCPHTTPServerStatus>;
export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise<Record<string, any>>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;

View File

@@ -58,6 +58,10 @@ export function AIGetMCPClientInstallStatuses() {
return window['go']['aiservice']['Service']['AIGetMCPClientInstallStatuses']();
}
export function AIGetMCPHTTPServerStatus() {
return window['go']['aiservice']['Service']['AIGetMCPHTTPServerStatus']();
}
export function AIGetMCPServers() {
return window['go']['aiservice']['Service']['AIGetMCPServers']();
}
@@ -134,6 +138,14 @@ export function AISetSafetyLevel(arg1) {
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
}
export function AIStartMCPHTTPServer(arg1) {
return window['go']['aiservice']['Service']['AIStartMCPHTTPServer'](arg1);
}
export function AIStopMCPHTTPServer() {
return window['go']['aiservice']['Service']['AIStopMCPHTTPServer']();
}
export function AITestMCPServer(arg1) {
return window['go']['aiservice']['Service']['AITestMCPServer'](arg1);
}

View File

@@ -1,5 +1,5 @@
export namespace ai {
export class MCPClientInstallResult {
success: boolean;
client?: string;
@@ -7,11 +7,11 @@ export namespace ai {
configPath?: string;
command?: string;
args?: string[];
static createFrom(source: any = {}) {
return new MCPClientInstallResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.success = source["success"];
@@ -35,7 +35,7 @@ export namespace ai {
configPath?: string;
command?: string;
args?: string[];
static createFrom(source: any = {}) {
return new MCPClientInstallStatus(source);
}
@@ -56,6 +56,52 @@ export namespace ai {
this.args = source["args"];
}
}
export class MCPHTTPServerOptions {
addr?: string;
path?: string;
token?: string;
schemaOnly: boolean;
static createFrom(source: any = {}) {
return new MCPHTTPServerOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.addr = source["addr"];
this.path = source["path"];
this.token = source["token"];
this.schemaOnly = source["schemaOnly"];
}
}
export class MCPHTTPServerStatus {
running: boolean;
addr: string;
path: string;
url: string;
schemaOnly: boolean;
token?: string;
authorizationHeader?: string;
startedAt?: number;
message: string;
static createFrom(source: any = {}) {
return new MCPHTTPServerStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.addr = source["addr"];
this.path = source["path"];
this.url = source["url"];
this.schemaOnly = source["schemaOnly"];
this.token = source["token"];
this.authorizationHeader = source["authorizationHeader"];
this.startedAt = source["startedAt"];
this.message = source["message"];
}
}
export class MCPServerConfig {
id: string;
name: string;

View File

@@ -0,0 +1,410 @@
package aiservice
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
const (
defaultMCPHTTPAddr = "127.0.0.1:8765"
defaultMCPHTTPPath = "/mcp"
)
type mcpHTTPServerRuntime struct {
process mcpHTTPProcess
status ai.MCPHTTPServerStatus
stopping bool
}
type mcpHTTPProcessStartOptions struct {
Addr string
Path string
Token string
SchemaOnly bool
}
type mcpHTTPProcess interface {
Done() <-chan struct{}
Stop(context.Context) error
Wait() error
}
var (
startMCPHTTPProcess = startMCPHTTPCommandProcess
waitMCPHTTPHealth = waitMCPHTTPHealthEndpoint
)
// AIGetMCPHTTPServerStatus 返回客户端内置 HTTP MCP 服务状态。
func (s *Service) AIGetMCPHTTPServerStatus() ai.MCPHTTPServerStatus {
s.mcpHTTPMu.Lock()
defer s.mcpHTTPMu.Unlock()
if s.mcpHTTP != nil {
status := s.mcpHTTP.status
status.Running = true
return status
}
if strings.TrimSpace(s.mcpHTTPLast.Addr) != "" {
return s.mcpHTTPLast
}
return defaultMCPHTTPServerStatus()
}
// AIStartMCPHTTPServer 从客户端内启动 GoNavi Streamable HTTP MCP 服务。
func (s *Service) AIStartMCPHTTPServer(options ai.MCPHTTPServerOptions) (ai.MCPHTTPServerStatus, error) {
s.mcpHTTPMu.Lock()
if s.mcpHTTP != nil {
status := s.mcpHTTP.status
status.Running = true
s.mcpHTTPMu.Unlock()
return status, nil
}
s.mcpHTTPMu.Unlock()
startOptions, token, err := normalizeInAppMCPHTTPOptions(options)
if err != nil {
return defaultMCPHTTPServerStatus(), err
}
ctx := s.ctx
if ctx == nil {
ctx = context.Background()
}
process, err := startMCPHTTPProcess(ctx, startOptions)
if err != nil {
status := stoppedMCPHTTPStatus(statusFromMCPHTTPOptions(startOptions, token), fmt.Sprintf("GoNavi MCP HTTP 服务启动失败:%v", err))
return status, err
}
status := statusFromMCPHTTPOptions(startOptions, token)
if err := waitForMCPHTTPReady(ctx, process, status); err != nil {
_ = process.Stop(context.Background())
stopped := stoppedMCPHTTPStatus(status, fmt.Sprintf("GoNavi MCP HTTP 服务启动失败:%v", err))
return stopped, err
}
runtime := &mcpHTTPServerRuntime{process: process, status: status}
s.mcpHTTPMu.Lock()
if s.mcpHTTP != nil {
existing := s.mcpHTTP.status
existing.Running = true
s.mcpHTTPMu.Unlock()
_ = process.Stop(context.Background())
return existing, nil
}
s.mcpHTTP = runtime
s.mcpHTTPLast = status
s.mcpHTTPMu.Unlock()
logger.Infof("客户端启动 GoNavi MCP HTTP 服务addr=%s path=%s schemaOnly=%v", status.Addr, status.Path, status.SchemaOnly)
go s.watchMCPHTTPServer(runtime)
return status, nil
}
// AIStopMCPHTTPServer 停止客户端内启动的 GoNavi Streamable HTTP MCP 服务。
func (s *Service) AIStopMCPHTTPServer() (ai.MCPHTTPServerStatus, error) {
return s.stopMCPHTTPServer(context.Background(), "GoNavi MCP HTTP 服务已停止")
}
func (s *Service) stopMCPHTTPServer(ctx context.Context, message string) (ai.MCPHTTPServerStatus, error) {
s.mcpHTTPMu.Lock()
runtime := s.mcpHTTP
if runtime == nil {
status := s.mcpHTTPLast
if strings.TrimSpace(status.Addr) == "" {
status = defaultMCPHTTPServerStatus()
}
status.Running = false
status.Token = ""
status.AuthorizationHeader = ""
status.Message = "GoNavi MCP HTTP 服务未启动"
s.mcpHTTPLast = status
s.mcpHTTPMu.Unlock()
return status, nil
}
runtime.stopping = true
s.mcpHTTPMu.Unlock()
if ctx == nil {
ctx = context.Background()
}
err := runtime.process.Stop(ctx)
status := stoppedMCPHTTPStatus(runtime.status, message)
if err != nil {
status.Message = fmt.Sprintf("GoNavi MCP HTTP 服务停止失败:%v", err)
}
s.mcpHTTPMu.Lock()
if s.mcpHTTP == runtime {
s.mcpHTTP = nil
s.mcpHTTPLast = status
}
s.mcpHTTPMu.Unlock()
if err == nil {
logger.Infof("客户端停止 GoNavi MCP HTTP 服务addr=%s path=%s", status.Addr, status.Path)
}
return status, err
}
// Shutdown 释放 AI Service 中的运行时资源。
func (s *Service) Shutdown(ctx context.Context) {
_, _ = s.stopMCPHTTPServer(ctx, "应用关闭GoNavi MCP HTTP 服务已停止")
}
func (s *Service) watchMCPHTTPServer(runtime *mcpHTTPServerRuntime) {
err := runtime.process.Wait()
s.mcpHTTPMu.Lock()
defer s.mcpHTTPMu.Unlock()
if s.mcpHTTP != runtime {
return
}
message := "GoNavi MCP HTTP 服务已停止"
if err != nil && !runtime.stopping {
message = fmt.Sprintf("GoNavi MCP HTTP 服务异常退出:%v", err)
logger.Error(err, "GoNavi MCP HTTP 服务异常退出addr=%s path=%s", runtime.status.Addr, runtime.status.Path)
}
s.mcpHTTP = nil
s.mcpHTTPLast = stoppedMCPHTTPStatus(runtime.status, message)
}
func normalizeInAppMCPHTTPOptions(options ai.MCPHTTPServerOptions) (mcpHTTPProcessStartOptions, string, error) {
addr := strings.TrimSpace(options.Addr)
if addr == "" {
addr = defaultMCPHTTPAddr
}
path := strings.TrimSpace(options.Path)
if path == "" {
path = defaultMCPHTTPPath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
token := strings.TrimSpace(options.Token)
if token == "" {
generated, err := generateMCPHTTPToken()
if err != nil {
return mcpHTTPProcessStartOptions{}, "", err
}
token = generated
}
return mcpHTTPProcessStartOptions{
Addr: addr,
Path: path,
Token: token,
SchemaOnly: true,
}, token, nil
}
func startMCPHTTPCommandProcess(ctx context.Context, options mcpHTTPProcessStartOptions) (mcpHTTPProcess, error) {
executable, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("定位当前 GoNavi 可执行文件失败: %w", err)
}
if ctx == nil {
ctx = context.Background()
}
processCtx, cancel := context.WithCancel(ctx)
args := []string{"mcp-server", "http", "--addr", options.Addr, "--path", options.Path}
if options.SchemaOnly {
args = append(args, "--schema-only")
}
cmd := exec.CommandContext(processCtx, executable, args...)
cmd.Env = append(os.Environ(), "GONAVI_MCP_HTTP_TOKEN="+options.Token)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
process := &mcpHTTPCommandProcess{
cancel: cancel,
cmd: cmd,
done: make(chan struct{}),
}
go process.wait()
return process, nil
}
type mcpHTTPCommandProcess struct {
cancel context.CancelFunc
cmd *exec.Cmd
done chan struct{}
mu sync.Mutex
err error
}
func (p *mcpHTTPCommandProcess) Done() <-chan struct{} {
return p.done
}
func (p *mcpHTTPCommandProcess) Stop(ctx context.Context) error {
if p == nil {
return nil
}
if p.cancel != nil {
p.cancel()
}
if ctx == nil {
ctx = context.Background()
}
select {
case <-p.done:
return p.waitErr()
case <-ctx.Done():
return ctx.Err()
}
}
func (p *mcpHTTPCommandProcess) Wait() error {
if p == nil {
return nil
}
<-p.done
return p.waitErr()
}
func (p *mcpHTTPCommandProcess) wait() {
err := p.cmd.Wait()
p.mu.Lock()
p.err = err
p.mu.Unlock()
close(p.done)
}
func (p *mcpHTTPCommandProcess) waitErr() error {
p.mu.Lock()
defer p.mu.Unlock()
return p.err
}
func waitForMCPHTTPReady(ctx context.Context, process mcpHTTPProcess, status ai.MCPHTTPServerStatus) error {
readyCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
healthErrCh := make(chan error, 1)
go func() {
healthErrCh <- waitMCPHTTPHealth(readyCtx, buildMCPHTTPURL(status.Addr, "/healthz"))
}()
select {
case err := <-healthErrCh:
return err
case <-process.Done():
if err := process.Wait(); err != nil {
return err
}
return fmt.Errorf("MCP HTTP 子进程已退出")
case <-readyCtx.Done():
return readyCtx.Err()
}
}
func waitMCPHTTPHealthEndpoint(ctx context.Context, healthURL string) error {
client := http.Client{Timeout: 300 * time.Millisecond}
var lastErr error
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
lastErr = fmt.Errorf("healthz 返回 HTTP %d", resp.StatusCode)
} else {
lastErr = err
}
select {
case <-ctx.Done():
if lastErr != nil {
return lastErr
}
return ctx.Err()
case <-time.After(120 * time.Millisecond):
}
}
}
func statusFromMCPHTTPOptions(options mcpHTTPProcessStartOptions, token string) ai.MCPHTTPServerStatus {
return ai.MCPHTTPServerStatus{
Running: true,
Addr: options.Addr,
Path: options.Path,
URL: buildMCPHTTPURL(options.Addr, options.Path),
SchemaOnly: true,
Token: token,
AuthorizationHeader: "Bearer " + token,
StartedAt: time.Now().UnixMilli(),
Message: "GoNavi MCP HTTP 服务已启动",
}
}
func generateMCPHTTPToken() (string, error) {
var bytes [24]byte
if _, err := rand.Read(bytes[:]); err != nil {
return "", fmt.Errorf("生成 MCP HTTP Token 失败: %w", err)
}
return "gnv_" + base64.RawURLEncoding.EncodeToString(bytes[:]), nil
}
func defaultMCPHTTPServerStatus() ai.MCPHTTPServerStatus {
return ai.MCPHTTPServerStatus{
Running: false,
Addr: defaultMCPHTTPAddr,
Path: defaultMCPHTTPPath,
URL: buildMCPHTTPURL(defaultMCPHTTPAddr, defaultMCPHTTPPath),
SchemaOnly: true,
Message: "GoNavi MCP HTTP 服务未启动",
}
}
func stoppedMCPHTTPStatus(status ai.MCPHTTPServerStatus, message string) ai.MCPHTTPServerStatus {
status.Running = false
status.Token = ""
status.AuthorizationHeader = ""
status.Message = message
return status
}
func buildMCPHTTPURL(addr string, path string) string {
path = strings.TrimSpace(path)
if path == "" {
path = defaultMCPHTTPPath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
host, port, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
return "http://" + strings.TrimSpace(addr) + path
}
host = strings.Trim(host, "[]")
if host == "" || host == "::" || host == "0.0.0.0" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port) + path
}

View File

@@ -0,0 +1,101 @@
package aiservice
import (
"context"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/secretstore"
)
type fakeMCPHTTPProcess struct {
done chan struct{}
once sync.Once
}
func newFakeMCPHTTPProcess() *fakeMCPHTTPProcess {
return &fakeMCPHTTPProcess{done: make(chan struct{})}
}
func (p *fakeMCPHTTPProcess) Done() <-chan struct{} {
return p.done
}
func (p *fakeMCPHTTPProcess) Stop(context.Context) error {
p.once.Do(func() {
close(p.done)
})
return nil
}
func (p *fakeMCPHTTPProcess) Wait() error {
<-p.done
return nil
}
func TestMCPHTTPServerLifecycleFromAIService(t *testing.T) {
originalStarter := startMCPHTTPProcess
originalHealth := waitMCPHTTPHealth
t.Cleanup(func() {
startMCPHTTPProcess = originalStarter
waitMCPHTTPHealth = originalHealth
})
var capturedOptions mcpHTTPProcessStartOptions
startMCPHTTPProcess = func(_ context.Context, options mcpHTTPProcessStartOptions) (mcpHTTPProcess, error) {
capturedOptions = options
return newFakeMCPHTTPProcess(), nil
}
waitMCPHTTPHealth = func(_ context.Context, _ string) error {
return nil
}
service := NewServiceWithSecretStore(secretstore.NewUnavailableStore("test"))
InitializeLifecycle(service, context.Background())
t.Cleanup(func() {
service.Shutdown(context.Background())
})
initial := service.AIGetMCPHTTPServerStatus()
if initial.Running {
t.Fatal("expected MCP HTTP server to be stopped initially")
}
started, err := service.AIStartMCPHTTPServer(ai.MCPHTTPServerOptions{
Addr: "127.0.0.1:0",
Path: "mcp",
})
if err != nil {
t.Fatalf("AIStartMCPHTTPServer returned error: %v", err)
}
if !started.Running {
t.Fatalf("expected running status, got %#v", started)
}
if started.Path != "/mcp" {
t.Fatalf("expected normalized path /mcp, got %q", started.Path)
}
if !started.SchemaOnly {
t.Fatal("expected in-app MCP HTTP server to default to schema-only mode")
}
if !capturedOptions.SchemaOnly || capturedOptions.Token != started.Token {
t.Fatalf("expected process to receive schema-only and generated token, got %#v", capturedOptions)
}
if !strings.HasPrefix(started.Token, "gnv_") || started.AuthorizationHeader != "Bearer "+started.Token {
t.Fatalf("expected generated bearer token in status, got token=%q header=%q", started.Token, started.AuthorizationHeader)
}
if !strings.Contains(started.URL, "/mcp") {
t.Fatalf("expected MCP URL to include /mcp, got %q", started.URL)
}
stopped, err := service.AIStopMCPHTTPServer()
if err != nil {
t.Fatalf("AIStopMCPHTTPServer returned error: %v", err)
}
if stopped.Running {
t.Fatalf("expected stopped status, got %#v", stopped)
}
if stopped.Token != "" || stopped.AuthorizationHeader != "" {
t.Fatalf("expected stopped status to clear token fields, got token=%q header=%q", stopped.Token, stopped.AuthorizationHeader)
}
}

View File

@@ -40,6 +40,9 @@ type Service struct {
configDir string // 配置存储目录
secretStore secretstore.SecretStore
cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数
mcpHTTPMu sync.Mutex
mcpHTTP *mcpHTTPServerRuntime
mcpHTTPLast ai.MCPHTTPServerStatus
}
var miniMaxAnthropicModels = []string{

View File

@@ -162,6 +162,27 @@ type MCPClientInstallStatus struct {
Args []string `json:"args,omitempty"`
}
// MCPHTTPServerOptions 表示从客户端启动 GoNavi Streamable HTTP MCP 的参数。
type MCPHTTPServerOptions struct {
Addr string `json:"addr,omitempty"`
Path string `json:"path,omitempty"`
Token string `json:"token,omitempty"`
SchemaOnly bool `json:"schemaOnly"`
}
// MCPHTTPServerStatus 表示客户端内置 HTTP MCP 服务运行状态。
type MCPHTTPServerStatus struct {
Running bool `json:"running"`
Addr string `json:"addr"`
Path string `json:"path"`
URL string `json:"url"`
SchemaOnly bool `json:"schemaOnly"`
Token string `json:"token,omitempty"`
AuthorizationHeader string `json:"authorizationHeader,omitempty"`
StartedAt int64 `json:"startedAt,omitempty"`
Message string `json:"message"`
}
// ClaudeCodeMCPInstallResult 兼容旧命名,便于平滑迁移到通用结果类型。
type ClaudeCodeMCPInstallResult = MCPClientInstallResult

View File

@@ -7,9 +7,11 @@ import (
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -29,6 +31,59 @@ type HTTPServerOptions struct {
SchemaOnly bool
}
// StreamableHTTPServerHandle 表示一个已启动的 Streamable HTTP MCP server。
type StreamableHTTPServerHandle struct {
Addr string
Path string
SchemaOnly bool
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
err error
}
// Stop 关闭 HTTP MCP server并等待底层 http.Server 完成退出。
func (h *StreamableHTTPServerHandle) Stop(ctx context.Context) error {
if h == nil {
return nil
}
if h.cancel != nil {
h.cancel()
}
if ctx == nil {
ctx = context.Background()
}
select {
case <-h.done:
return h.waitErr()
case <-ctx.Done():
return ctx.Err()
}
}
// Wait 阻塞直到 HTTP MCP server 退出。
func (h *StreamableHTTPServerHandle) Wait() error {
if h == nil {
return nil
}
<-h.done
return h.waitErr()
}
func (h *StreamableHTTPServerHandle) complete(err error) {
h.mu.Lock()
h.err = err
h.mu.Unlock()
close(h.done)
}
func (h *StreamableHTTPServerHandle) waitErr() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.err
}
// RunAppStdioServer 启动基于真实 GoNavi App 的 stdio MCP server。
func RunAppStdioServer(ctx context.Context) error {
if ctx == nil {
@@ -51,27 +106,57 @@ func RunStdioServer(ctx context.Context, backend Backend) error {
return server.Run(ctx, &mcp.StdioTransport{})
}
// StartAppStreamableHTTPServer 启动基于真实 GoNavi App 的 Streamable HTTP MCP server并立即返回可停止句柄。
func StartAppStreamableHTTPServer(ctx context.Context, options HTTPServerOptions) (*StreamableHTTPServerHandle, error) {
if ctx == nil {
ctx = context.Background()
}
backend := NewAppBackend(ctx)
handle, err := StartStreamableHTTPServer(ctx, backend, options)
if err != nil {
_ = backend.Close(context.Background())
return nil, err
}
go func() {
_ = handle.Wait()
_ = backend.Close(context.Background())
}()
return handle, nil
}
// RunAppStreamableHTTPServer 启动基于真实 GoNavi App 的 Streamable HTTP MCP server。
func RunAppStreamableHTTPServer(ctx context.Context, options HTTPServerOptions) error {
if ctx == nil {
ctx = context.Background()
}
backend := NewAppBackend(ctx)
defer backend.Close(ctx)
return RunStreamableHTTPServer(ctx, backend, options)
handle, err := StartAppStreamableHTTPServer(ctx, options)
if err != nil {
return err
}
return handle.Wait()
}
// RunStreamableHTTPServer 使用指定 backend 启动带 bearer token 的 Streamable HTTP MCP server。
func RunStreamableHTTPServer(ctx context.Context, backend Backend, options HTTPServerOptions) error {
handle, err := StartStreamableHTTPServer(ctx, backend, options)
if err != nil {
return err
}
return handle.Wait()
}
// StartStreamableHTTPServer 使用指定 backend 启动带 bearer token 的 Streamable HTTP MCP server并返回可停止句柄。
func StartStreamableHTTPServer(ctx context.Context, backend Backend, options HTTPServerOptions) (*StreamableHTTPServerHandle, error) {
if ctx == nil {
ctx = context.Background()
}
normalized, err := normalizeHTTPServerOptions(options)
if err != nil {
return err
return nil, err
}
server := NewServerWithOptions(backend, ServerOptions{SchemaOnly: normalized.SchemaOnly})
@@ -95,25 +180,52 @@ func RunStreamableHTTPServer(ctx context.Context, backend Backend, options HTTPS
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", normalized.Addr)
if err != nil {
return nil, err
}
serverCtx, cancel := context.WithCancel(ctx)
handle := &StreamableHTTPServerHandle{
Addr: listener.Addr().String(),
Path: normalized.Path,
SchemaOnly: normalized.SchemaOnly,
cancel: cancel,
done: make(chan struct{}),
}
errCh := make(chan error, 1)
go func() {
errCh <- httpServer.ListenAndServe()
errCh <- httpServer.Serve(listener)
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
go func() {
select {
case err := <-errCh:
cancel()
if errors.Is(err, http.ErrServerClosed) {
handle.complete(nil)
return
}
handle.complete(err)
case <-serverCtx.Done():
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
shutdownErr := httpServer.Shutdown(shutdownCtx)
serveErr := <-errCh
if shutdownErr != nil && !errors.Is(shutdownErr, http.ErrServerClosed) {
handle.complete(shutdownErr)
return
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
handle.complete(serveErr)
return
}
handle.complete(nil)
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
}()
return handle, nil
}
// ParseHTTPServerOptions 解析 http 模式参数,并支持环境变量兜底。

View File

@@ -50,7 +50,10 @@ func main() {
app.InitializeLifecycle(application, ctx)
aiservice.InitializeLifecycle(aiService, ctx)
},
OnShutdown: application.Shutdown,
OnShutdown: func(ctx context.Context) {
aiService.Shutdown(ctx)
application.Shutdown(ctx)
},
Bind: []interface{}{
application,
aiService,