feat(mcp): 优化新增服务模板入口

- 模板入口移入一行命令快速新增面板

- 增加启动命令预览,降低 command 和 args 拆分成本

- 移除设置页重复模板区块并补充交互测试
This commit is contained in:
Syngnat
2026-06-12 01:41:31 +08:00
parent 156fce531c
commit 4cac8ef3c9
4 changed files with 129 additions and 108 deletions

View File

@@ -1,28 +1,109 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel';
vi.mock('antd', async () => {
const React = await import('react');
return {
Input: {
TextArea: (props: any) => React.createElement('textarea', props),
},
Button: ({ icon, children, ...props }: any) => React.createElement('button', props, icon, children),
};
});
vi.mock('@ant-design/icons', async () => {
const React = await import('react');
return {
PlusOutlined: () => React.createElement('span', { 'data-testid': 'plus-icon' }),
};
});
const buildQuickAddPanel = (onAddServer = () => {}) => (
<AIMCPQuickAddServerPanel
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
onAddServer={onAddServer}
/>
);
const flattenRendererText = (node: any): string => {
if (node == null || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map((item) => flattenRendererText(item)).join('');
}
return flattenRendererText(node.children ?? node.props?.children);
};
const findTemplateButton = (renderer: ReactTestRenderer, label: string) => {
const matches = renderer.root.findAll(
(node) => node.type === 'button' && flattenRendererText(node).includes(label),
);
expect(matches.length).toBe(1);
return matches[0];
};
describe('AIMCPQuickAddServerPanel', () => {
it('renders a top-level full-command entry for creating MCP drafts', () => {
const markup = renderToStaticMarkup(
<AIMCPQuickAddServerPanel
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
onAddServer={() => {}}
/>,
buildQuickAddPanel(),
);
expect(markup).toContain('一行命令快速新增');
expect(markup).toContain('README 里通常只给一整行启动命令');
expect(markup).toContain('先选最接近的模板');
expect(markup).toContain('command、args 和 env');
expect(markup).toContain('常见启动方式模板');
expect(markup).toContain('npx 包');
expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio');
expect(markup).toContain('Docker 镜像');
expect(markup).toContain('docker run --rm -i mcp/server-fetch:latest');
expect(markup).toContain('粘贴完整命令');
expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio');
expect(markup).toContain('解析并新增草稿');
});
it('seeds a new npx MCP draft from the quick-add template', async () => {
const onAddServer = vi.fn();
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(buildQuickAddPanel(onAddServer));
});
findTemplateButton(renderer, 'npx 包').props.onClick();
expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'],
}));
});
it('seeds a docker MCP draft from the quick-add template', async () => {
const onAddServer = vi.fn();
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(buildQuickAddPanel(onAddServer));
});
findTemplateButton(renderer, 'Docker 镜像').props.onClick();
expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({
command: 'docker',
args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'],
timeoutSeconds: 45,
}));
});
});

View File

@@ -7,8 +7,12 @@ import {
parseMCPCommandDraft,
type ParseMCPCommandDraftResult,
} from '../../utils/mcpCommandDraft';
import { MCP_COMMAND_PARSE_EXAMPLE } from '../../utils/mcpServerGuidance';
import {
buildMCPLaunchPreview,
MCP_COMMAND_PARSE_EXAMPLE,
} from '../../utils/mcpServerGuidance';
import { buildMCPQuickAddServerSeed } from '../../utils/mcpServerDraftSeed';
import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview';
import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
@@ -75,7 +79,38 @@ const AIMCPQuickAddServerPanel: React.FC<AIMCPQuickAddServerPanelProps> = ({
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText, fontSize: 14 }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
README GoNavi commandargs env MCP 稿
README GoNavi commandargs env MCP 稿
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
command args 稿 GoNavi
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
<button
key={template.key}
type="button"
onClick={() => onAddServer(template.seed)}
style={{
textAlign: 'left',
padding: '12px 13px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)',
color: overlayTheme.titleText,
cursor: 'pointer',
}}
>
<div style={{ fontSize: 13, fontWeight: 700 }}>{template.title}</div>
<div style={{ marginTop: 4, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.description}</div>
<code style={{ display: 'block', marginTop: 8, fontFamily: 'var(--gn-font-mono)', fontSize: 12, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: overlayTheme.titleText }}>
{buildMCPLaunchPreview(String(template.seed.command || ''), template.seed.args)}
</code>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.detail}</div>
</button>
))}
</div>
</div>
<Input.TextArea

View File

@@ -6,19 +6,6 @@ import AISettingsMCPSection from './AISettingsMCPSection';
import type { AISettingsMCPSectionProps } from './AISettingsMCPSection';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
const flattenElementText = (node: any): string => {
if (node == null || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map((item) => flattenElementText(item)).join('');
}
return flattenElementText(node.props?.children);
};
const findElement = (node: any, predicate: (element: any) => boolean): any => {
if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
return null;
@@ -118,7 +105,7 @@ describe('AISettingsMCPSection', () => {
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('尚未把当前 GoNavi MCP 接入到这里');
expect(markup).toContain('一行命令快速新增');
expect(markup).toContain('README 里通常只给一整行启动命令');
expect(markup).toContain('先选最接近的模板');
expect(markup).toContain('解析并新增草稿');
expect(markup).toContain('新增 MCP 参数速查');
expect(markup).toContain('command');
@@ -206,49 +193,6 @@ describe('AISettingsMCPSection', () => {
expect(markup).toContain('未声明 inputSchema');
});
it('seeds a new draft when a launch template is selected', () => {
const onAddServer = vi.fn();
const tree = AISettingsMCPSection(buildMCPSectionProps({
mcpClientStatuses: [],
selectedMCPClientStatus: undefined,
onAddServer,
}));
const npxTemplateButton = findElement(
tree,
(node) => node.type === 'button' && flattenElementText(node.props?.children).includes('npx 包'),
);
expect(npxTemplateButton).toBeTruthy();
npxTemplateButton.props.onClick();
expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'],
}));
});
it('seeds a docker MCP draft with interactive stdio args', () => {
const onAddServer = vi.fn();
const tree = AISettingsMCPSection(buildMCPSectionProps({
mcpClientStatuses: [],
selectedMCPClientStatus: undefined,
onAddServer,
}));
const dockerTemplateButton = findElement(
tree,
(node) => node.type === 'button' && flattenElementText(node.props?.children).includes('Docker 镜像'),
);
expect(dockerTemplateButton).toBeTruthy();
dockerTemplateButton.props.onClick();
expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({
command: 'docker',
args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'],
timeoutSeconds: 45,
}));
});
it('toggles the in-app MCP HTTP service from the switch panel', () => {
const onToggleHTTPServer = vi.fn();
const tree = AISettingsMCPSection(buildMCPSectionProps({

View File

@@ -5,7 +5,6 @@ import { PlusOutlined } from '@ant-design/icons';
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';
@@ -141,44 +140,6 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
))}
</div>
</div>
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi Docker exe
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
<button
key={template.key}
type="button"
onClick={() => onAddServer(template.seed)}
style={{
textAlign: 'left',
padding: '12px 13px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)',
color: overlayTheme.titleText,
cursor: 'pointer',
}}
>
<div style={{ fontSize: 13, fontWeight: 700 }}>{template.title}</div>
<div style={{ marginTop: 4, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.description}</div>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.detail}</div>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
<Button icon={<PlusOutlined />} onClick={() => onAddServer()} style={{ borderRadius: 10 }}> MCP </Button>