diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx new file mode 100644 index 0000000..391e830 --- /dev/null +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import AIMCPQuickAddServerPanel, { buildMCPQuickAddServerSeed } from './AIMCPQuickAddServerPanel'; + +describe('AIMCPQuickAddServerPanel', () => { + it('renders a top-level full-command entry for creating MCP drafts', () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain('一行命令快速新增'); + expect(markup).toContain('README 里通常只给一整行启动命令'); + expect(markup).toContain('command、args 和 env'); + expect(markup).toContain('粘贴完整命令'); + expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); + expect(markup).toContain('解析并新增草稿'); + }); + + it('builds an editable draft seed from a parsed uvx command with env vars', () => { + const parsed = parseMCPCommandDraft('$env:GITHUB_TOKEN=***; uvx mcp-server-github --stdio'); + + expect(parsed.ok).toBe(true); + const seed = buildMCPQuickAddServerSeed(parsed.draft!); + + expect(seed).toMatchObject({ + name: 'mcp-server-github', + transport: 'stdio', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + env: { GITHUB_TOKEN: '***' }, + enabled: true, + timeoutSeconds: 20, + }); + }); + + it('uses a wider default timeout and image-based name for docker drafts', () => { + const parsed = parseMCPCommandDraft('docker run --rm -i -e API_KEY=*** mcp/server-fetch:latest'); + + expect(parsed.ok).toBe(true); + const seed = buildMCPQuickAddServerSeed(parsed.draft!); + + expect(seed).toMatchObject({ + name: 'server-fetch:latest', + command: 'docker', + args: ['run', '--rm', '-i', '-e', 'API_KEY=***', 'mcp/server-fetch:latest'], + timeoutSeconds: 45, + }); + }); +}); diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx new file mode 100644 index 0000000..1c7651c --- /dev/null +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { Button, Input } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; + +import type { AIMCPServerConfig } from '../../types'; +import { + parseMCPCommandDraft, + type ParsedMCPCommandDraft, + type ParseMCPCommandDraftResult, +} from '../../utils/mcpCommandDraft'; +import { MCP_COMMAND_PARSE_EXAMPLE } from '../../utils/mcpServerGuidance'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview'; +import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock'; + +interface AIMCPQuickAddServerPanelProps { + cardBg: string; + cardBorder: string; + inputBg: string; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + onAddServer: (seed?: Partial) => void; +} + +const stripCommandSuffix = (value: string): string => + value.replace(/\.(exe|cmd|bat|ps1|c?m?[jt]s|py)$/iu, ''); + +const toDisplayNamePart = (value: string): string => { + const text = String(value || '').trim(); + if (!text) return ''; + const lastPathPart = text.split(/[\\/]/u).filter(Boolean).pop() || text; + const packagePart = lastPathPart.includes('/') ? lastPathPart.split('/').filter(Boolean).pop() || lastPathPart : lastPathPart; + return stripCommandSuffix(packagePart).replace(/^@/u, '').trim(); +}; + +const findDockerImageArg = (args: string[]): string => { + const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run'); + const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args; + const optionsWithValue = new Set([ + '-e', + '--env', + '--name', + '--network', + '-v', + '--volume', + '-p', + '--publish', + '--entrypoint', + '-w', + '--workdir', + '-u', + '--user', + '--platform', + '-h', + '--hostname', + ]); + + for (let index = 0; index < candidates.length; index += 1) { + const arg = String(candidates[index] || '').trim(); + if (!arg) continue; + if (arg.startsWith('-')) { + if (optionsWithValue.has(arg.toLowerCase())) { + index += 1; + } + continue; + } + if (arg.includes('=') || arg.toLowerCase() === 'run') { + continue; + } + return arg; + } + return ''; +}; + +const pickDraftNameCandidate = (draft: ParsedMCPCommandDraft): string => { + const commandName = toDisplayNamePart(draft.command).toLowerCase(); + const args = draft.args || []; + + if (['npx', 'npm', 'pnpm', 'yarn', 'uvx', 'uv'].includes(commandName)) { + return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || draft.command; + } + if (['node', 'bun', 'deno'].includes(commandName)) { + return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || draft.command; + } + if (['python', 'python3', 'py'].includes(commandName)) { + const moduleFlagIndex = args.findIndex((arg) => arg === '-m'); + return (moduleFlagIndex >= 0 ? args[moduleFlagIndex + 1] : '') || args.find((arg) => arg && !arg.startsWith('-')) || draft.command; + } + if (commandName === 'docker') { + return findDockerImageArg(args) || draft.command; + } + return draft.command; +}; + +export const buildMCPQuickAddServerSeed = ( + draft: ParsedMCPCommandDraft, +): Partial => { + const commandName = toDisplayNamePart(draft.command).toLowerCase(); + const namePart = toDisplayNamePart(pickDraftNameCandidate(draft)) || 'MCP 服务'; + + return { + name: namePart, + transport: 'stdio', + command: draft.command, + args: draft.args, + env: draft.env, + enabled: true, + timeoutSeconds: commandName === 'docker' ? 45 : 20, + }; +}; + +const renderParseSummary = ( + rawCommandDraft: string, + parsedCommandDraft: ParseMCPCommandDraftResult, + overlayTheme: OverlayWorkbenchTheme, +) => { + if (!rawCommandDraft.trim()) { + return '支持带引号路径、带空格参数,以及 KEY=VALUE / $env:KEY=VALUE; / set KEY=VALUE && 环境变量前缀。'; + } + if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { + return parsedCommandDraft.error || '完整命令解析失败,请检查命令格式。'; + } + const envCount = Object.keys(parsedCommandDraft.draft.env || {}).length; + return ( + + 将解析为:命令 {parsedCommandDraft.draft.command},参数 {parsedCommandDraft.draft.args.length} 个,环境变量 {envCount} 个。 + + ); +}; + +const AIMCPQuickAddServerPanel: React.FC = ({ + cardBg, + cardBorder, + inputBg, + darkMode, + overlayTheme, + onAddServer, +}) => { + const [rawCommandDraft, setRawCommandDraft] = React.useState(''); + const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft); + + const handleAddFromCommand = () => { + if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { + return; + } + onAddServer(buildMCPQuickAddServerSeed(parsedCommandDraft.draft)); + setRawCommandDraft(''); + }; + + return ( +
+
+
一行命令快速新增
+
+ README 里通常只给一整行启动命令。直接粘到这里,GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。 +
+
+ setRawCommandDraft(event.target.value)} + placeholder={`粘贴完整命令,例如:\n${MCP_COMMAND_PARSE_EXAMPLE}`} + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }} + /> +
+
+ {renderParseSummary(rawCommandDraft, parsedCommandDraft, overlayTheme)} +
+ +
+ {parsedCommandDraft.ok && parsedCommandDraft.draft && rawCommandDraft.trim() && ( + + )} +
+ ); +}; + +export default AIMCPQuickAddServerPanel; diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index 77e85fc..16504ee 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -59,7 +59,7 @@ describe('AIMCPServerCard', () => { expect(markup).toContain('必填参数看起来已经齐了'); expect(markup).toContain('每行一个 KEY=VALUE'); expect(markup).toContain('没有等号或 key 含空格的行不会保存'); - expect(markup).toContain('不要把 npx -y package --stdio 或 node server.js --stdio 整串都塞进这里'); + expect(markup).toContain('不要把 npx -y package --stdio、node server.js --stdio 或 docker run -i image 整串都塞进这里'); expect(markup).toContain('不要写 export'); expect(markup).toContain('当前阶段只支持 stdio'); expect(markup).toContain('实际启动命令预览'); diff --git a/frontend/src/components/ai/AISettingsMCPSection.test.tsx b/frontend/src/components/ai/AISettingsMCPSection.test.tsx index e4a3438..ec22eff 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.test.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.test.tsx @@ -117,6 +117,9 @@ describe('AISettingsMCPSection', () => { expect(markup).toContain('复制 Authorization'); expect(markup).toContain('接入外部客户端'); expect(markup).toContain('尚未把当前 GoNavi MCP 接入到这里'); + expect(markup).toContain('一行命令快速新增'); + expect(markup).toContain('README 里通常只给一整行启动命令'); + expect(markup).toContain('解析并新增草稿'); expect(markup).toContain('新增 MCP 参数速查'); expect(markup).toContain('command'); expect(markup).toContain('args'); diff --git a/frontend/src/components/ai/AISettingsMCPSection.tsx b/frontend/src/components/ai/AISettingsMCPSection.tsx index 3c7eb45..2540653 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.tsx @@ -10,6 +10,7 @@ import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPClientInstallPanel from './AIMCPClientInstallPanel'; import AIMCPFieldGuideCard from './AIMCPFieldGuideCard'; import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel'; +import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel'; import AIMCPServerCard from './AIMCPServerCard'; export type { MCPClientKey } from '../../utils/mcpClientInstallStatus'; @@ -104,6 +105,14 @@ const AISettingsMCPSection: React.FC = ({ onCopyLaunchCommand={onCopyLaunchCommand} onInstall={onInstallSelectedClient} /> +