mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 22:42:55 +08:00
✨ feat(ai): 增加 MCP HTTP 服务与 Docker 配置诊断
- AI 设置页新增 GoNavi MCP HTTP 服务开关与状态展示 - 后端新增 HTTP MCP 子进程生命周期管理和鉴权配置 - 增加 Docker MCP 配置诊断工具与参数提示校验
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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');
|
||||
|
||||
38
frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx
Normal file
38
frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
115
frontend/src/components/ai/AIMCPHTTPServerPanel.tsx
Normal file
115
frontend/src/components/ai/AIMCPHTTPServerPanel.tsx
Normal 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 }}>
|
||||
给 OpenClaw、Hermans 等远程 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 首次拉起可能较慢');
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/ai/aiMCPDockerInsights.test.ts
Normal file
89
frontend/src/components/ai/aiMCPDockerInsights.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
201
frontend/src/components/ai/aiMCPDockerInsights.ts
Normal file
201
frontend/src/components/ai/aiMCPDockerInsights.ts
Normal 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 服务',
|
||||
};
|
||||
};
|
||||
@@ -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 提供的镜像名');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.js,python 需要 -m 模块名,uvx 需要包名。');
|
||||
actions.push('给启动器补齐参数:npx 通常需要 -y 和包名,node 需要 server.js,python 需要 -m 模块名,uvx 需要包名,docker 需要 run、-i 和镜像名。');
|
||||
}
|
||||
if (issueKeys.has('docker-run-missing')) {
|
||||
actions.push('Docker MCP 的 command 填 docker,args 里单独补 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。');
|
||||
|
||||
@@ -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 配置探针失败';
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user