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 配置探针失败';