diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index b929528..2d7e945 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); @@ -46,6 +55,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [contextLevel, setContextLevel] = useState('schema_only'); const [mcpServers, setMCPServers] = useState([]); const [mcpTools, setMCPTools] = useState([]); + const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState(DEFAULT_MCP_HTTP_SERVER_STATUS); + const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false); const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); const [isEditing, setIsEditing] = useState(false); @@ -142,7 +153,7 @@ const AISettingsModal: React.FC = ({ 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(() => Service.AIGetSafetyLevel?.(), 'readonly'), callOrFallback(() => Service.AIGetContextLevel?.(), 'schema_only'), @@ -150,6 +161,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS), callOrFallback(() => Service.AIGetMCPServers?.(), []), callOrFallback(() => Service.AIListMCPTools?.(), []), + callOrFallback(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS), callOrFallback(() => Service.AIGetSkills?.(), []), callOrFallback(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES), ]); @@ -169,6 +181,12 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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) => { setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item)); }; @@ -653,6 +723,7 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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()} diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index de27673..5089274 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -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'); diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx new file mode 100644 index 0000000..a211a46 --- /dev/null +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx @@ -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( + {}} + 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'); + }); +}); diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx new file mode 100644 index 0000000..45b2e26 --- /dev/null +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx @@ -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 = ({ + 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 ( +
+
+
+
+
GoNavi MCP HTTP 服务
+ + {running ? '已启动' : '未启动'} + + + schema-only + +
+
+ 给 OpenClaw、Hermans 等远程 Agent 使用。打开后默认监听本机地址,自动生成 Bearer Token,只开放连接、库表、字段和 DDL 等结构读取工具。 +
+
+ +
+
+
+ {running + ? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。' + : '不用再手动执行 GoNavi.exe mcp-server http 命令;在这里打开开关即可启动本机 HTTP MCP。'} +
+
+ + {url || 'http://127.0.0.1:8765/mcp'} + + + +
+
+
+ ); +}; + +export default AIMCPHTTPServerPanel; diff --git a/frontend/src/components/ai/AISettingsMCPSection.test.tsx b/frontend/src/components/ai/AISettingsMCPSection.test.tsx index 54a60ec..e4a3438 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.test.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.test.tsx @@ -71,6 +71,14 @@ const buildMCPSectionProps = (patch: Partial = {}): 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 = {}): A inputBg: '#fff', loading: false, mcpClientStatusLoading: false, + mcpHTTPServerLoading: false, + onToggleHTTPServer: () => {}, + onCopyHTTPServerURL: () => {}, + onCopyHTTPServerAuthorization: () => {}, onSelectClient: () => {}, onRefreshStatus: () => {}, onCopyConfigPath: () => {}, @@ -99,6 +111,10 @@ describe('AISettingsMCPSection', () => { , ); + 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); + }); }); diff --git a/frontend/src/components/ai/AISettingsMCPSection.tsx b/frontend/src/components/ai/AISettingsMCPSection.tsx index 6f0e891..3c7eb45 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.tsx @@ -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 = ({ selectedMCPClient, selectedMCPClientStatus, selectedMCPClientCommandText, + mcpHTTPServerStatus, mcpServers, mcpTools, darkMode, @@ -53,6 +60,10 @@ const AISettingsMCPSection: React.FC = ({ inputBg, loading, mcpClientStatusLoading, + mcpHTTPServerLoading, + onToggleHTTPServer, + onCopyHTTPServerURL, + onCopyHTTPServerAuthorization, onSelectClient, onRefreshStatus, onCopyConfigPath, @@ -65,6 +76,17 @@ const AISettingsMCPSection: React.FC = ({ onDeleteServer, }) => (
+ ({ + id: 'conn-1', + name: '主库', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, +}); + +const buildToolCall = (name: string, args: Record): 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 首次拉起可能较慢'); + }); +}); diff --git a/frontend/src/components/ai/aiMCPDockerInsights.test.ts b/frontend/src/components/ai/aiMCPDockerInsights.test.ts new file mode 100644 index 0000000..c0c3ba0 --- /dev/null +++ b/frontend/src/components/ai/aiMCPDockerInsights.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/components/ai/aiMCPDockerInsights.ts b/frontend/src/components/ai/aiMCPDockerInsights.ts new file mode 100644 index 0000000..c833a40 --- /dev/null +++ b/frontend/src/components/ai/aiMCPDockerInsights.ts @@ -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; + discoveredToolCount: number; +}): string[] => { + const actions: string[] = []; + if (!params.hasRun) { + actions.push('在 args 中补充 run,例如 docker run --rm -i '); + } + 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 ,可在 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 服务', + }; +}; diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts index ef1a4d7..1d2b0c5 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts @@ -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 提供的镜像名'); + }); }); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts index f900813..5db9f4a 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts @@ -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。'); diff --git a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts index 953bd8f..9da2bf7 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts @@ -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 配置探针失败'; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 26509c6..2f04a9f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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' diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a090563..6e1ffce 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { diff --git a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts index ae8fb81..c4936c7 100644 --- a/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts @@ -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" }, }, }, diff --git a/frontend/src/utils/aiBuiltinToolCatalog.ts b/frontend/src/utils/aiBuiltinToolCatalog.ts index 7d0fb41..74b4824 100644 --- a/frontend/src/utils/aiBuiltinToolCatalog.ts +++ b/frontend/src/utils/aiBuiltinToolCatalog.ts @@ -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', diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 55cf94c..ce57973 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -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', () => { diff --git a/frontend/src/utils/mcpArgumentHints.ts b/frontend/src/utils/mcpArgumentHints.ts index a9c52d5..57876c9 100644 --- a/frontend/src/utils/mcpArgumentHints.ts +++ b/frontend/src/utils/mcpArgumentHints.ts @@ -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; diff --git a/frontend/src/utils/mcpServerValidation.ts b/frontend/src/utils/mcpServerValidation.ts index c5cf83b..f1b0370 100644 --- a/frontend/src/utils/mcpServerValidation.ts +++ b/frontend/src/utils/mcpServerValidation.ts @@ -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; diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 54efd24..9837835 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -30,6 +30,8 @@ export function AIGetEditableProvider(arg1:string):Promise; export function AIGetMCPClientInstallStatuses():Promise>; +export function AIGetMCPHTTPServerStatus():Promise; + export function AIGetMCPServers():Promise>; export function AIGetProviders():Promise>; @@ -68,6 +70,10 @@ export function AISetContextLevel(arg1:string):Promise; export function AISetSafetyLevel(arg1:string):Promise; +export function AIStartMCPHTTPServer(arg1:ai.MCPHTTPServerOptions):Promise; + +export function AIStopMCPHTTPServer():Promise; + export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise>; export function AITestProvider(arg1:ai.ProviderConfig):Promise>; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index 40900e5..9858fd4 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 400de7d..886fb3e 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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; diff --git a/internal/ai/service/mcp_http_server.go b/internal/ai/service/mcp_http_server.go new file mode 100644 index 0000000..1d18ebb --- /dev/null +++ b/internal/ai/service/mcp_http_server.go @@ -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 +} diff --git a/internal/ai/service/mcp_http_server_test.go b/internal/ai/service/mcp_http_server_test.go new file mode 100644 index 0000000..f058479 --- /dev/null +++ b/internal/ai/service/mcp_http_server_test.go @@ -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) + } +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 183c65e..e6c81cb 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -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{ diff --git a/internal/ai/types.go b/internal/ai/types.go index b4b4ef8..c296e50 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -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 diff --git a/internal/mcpserver/run.go b/internal/mcpserver/run.go index 189e12b..b936846 100644 --- a/internal/mcpserver/run.go +++ b/internal/mcpserver/run.go @@ -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 模式参数,并支持环境变量兜底。 diff --git a/main.go b/main.go index ec702f6..69ac507 100644 --- a/main.go +++ b/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,