mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 02:49:49 +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;
|
||||
|
||||
6
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
6
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
@@ -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>>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
410
internal/ai/service/mcp_http_server.go
Normal file
410
internal/ai/service/mcp_http_server.go
Normal 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
|
||||
}
|
||||
101
internal/ai/service/mcp_http_server_test.go
Normal file
101
internal/ai/service/mcp_http_server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 模式参数,并支持环境变量兜底。
|
||||
|
||||
5
main.go
5
main.go
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user