feat(ai): 完善 MCP Docker 启动参数指引

- 新增 Docker MCP 启动模板和参数顺序提示

- 校验 docker run、-i 和镜像名等易漏参数

- 同步 MCP 设置页说明、空状态和单元测试
This commit is contained in:
Syngnat
2026-06-11 15:45:52 +08:00
parent e4672062f8
commit c9053bccc5
9 changed files with 196 additions and 19 deletions

View File

@@ -75,11 +75,11 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
options={[{ label: 'stdio', value: 'stdio' }]}
/>
</AIMCPHelpBlock>
<AIMCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 npx/node/uvx/python 这类启动器,把包名、脚本名模块名放到下面的参数里。不要把 npx -y package --stdionode server.js --stdio 整串都塞进这里。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="npx / node / uvx / python">
<AIMCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 npx/node/uvx/python/docker 这类启动器,把包名、脚本名模块名或 docker run 参数放到下面的参数里。不要把 npx -y package --stdionode server.js --stdio 或 docker run -i image 整串都塞进这里。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="npx / node / uvx / python / docker">
<Input
value={server.command}
onChange={(event) => onChange({ command: event.target.value })}
placeholder="启动命令例如npx / node / uvx / python"
placeholder="启动命令例如npx / node / uvx / python / docker"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</AIMCPHelpBlock>
@@ -122,7 +122,7 @@ const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
</AIMCPHelpBlock>
</div>
<AIMCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 npx -y package --stdio要把 -y、package 和 --stdio 分开填node server.js --stdio 要把 server.js 和 --stdio 分开填。不确定怎么拆时,优先回到上面的“完整命令”框自动拆分。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="-y、@modelcontextprotocol/server-filesystem、--stdio、server.js">
<AIMCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 npx -y package --stdio要把 -y、package 和 --stdio 分开填node server.js --stdio 要把 server.js 和 --stdio 分开填docker run --rm -i image 要把 run、--rm、-i 和镜像名分开填。不确定怎么拆时,优先回到上面的“完整命令”框自动拆分。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="-y、@modelcontextprotocol/server-filesystem、--stdio、server.js、run、--rm、-i、image">
<Select
mode="tags"
value={server.args || []}

View File

@@ -108,10 +108,10 @@ describe('AISettingsMCPSection', () => {
expect(markup).toContain('timeout');
expect(markup).toContain('只填程序名或启动器本身');
expect(markup).toContain('应填:');
expect(markup).toContain('填 npx、node、uvx、python或某个 exe 的绝对路径');
expect(markup).toContain('填 npx、node、uvx、python、docker,或某个 exe 的绝对路径');
expect(markup).toContain('不要填整行命令,例如不要填 npx -y pkg --stdio');
expect(markup).toContain('把脚本名、模块名、开关参数拆开逐项填写');
expect(markup).toContain('不要再填 npx/node/uvx/python');
expect(markup).toContain('不要再填 npx/node/uvx/python/docker');
expect(markup).toContain('给 MCP Server 传入 KEY=VALUE 形式的配置');
expect(markup).toContain('不要写 export、set 或 $env: 前缀');
expect(markup).toContain('单次工具发现或调用最多等待多久');
@@ -119,6 +119,8 @@ describe('AISettingsMCPSection', () => {
expect(markup).toContain('npx 包');
expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio');
expect(markup).toContain('Node 脚本');
expect(markup).toContain('Docker 镜像');
expect(markup).toContain('docker run -i --rm image');
expect(markup).toContain('新增 MCP 服务');
expect(markup).toContain('还没有 MCP 服务');
expect(markup).toContain('npx -y package --stdio');
@@ -205,4 +207,26 @@ describe('AISettingsMCPSection', () => {
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,
}));
});
});

View File

@@ -123,7 +123,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi exe
GoNavi Docker exe
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
@@ -154,7 +154,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
</div>
{mcpServers.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
MCP `npx -y package --stdio``node server.js``uvx some-mcp-server``python -m server`
MCP `npx -y package --stdio``node server.js``uvx some-mcp-server``python -m server``docker run --rm -i image`
</div>
)}
{mcpServers.map((server) => (

View File

@@ -28,6 +28,15 @@ describe('mcpArgumentHints', () => {
expect(profile?.nextActions).toContain('补充 模块名示例your_mcp_server');
});
it('guides docker users to keep stdin and provide an image', () => {
const profile = buildMCPArgumentHintProfile('docker', ['run', '--rm']);
expect(profile?.title).toContain('Docker');
expect(profile?.orderHint).toContain('run -> --rm -> -i');
expect(profile?.nextActions).toContain('补充 保持标准输入,示例:-i');
expect(profile?.nextActions).toContain('补充 镜像名示例mcp/server-fetch:latest');
});
it('falls back to executable guidance for custom binaries', () => {
const profile = buildMCPArgumentHintProfile('D:\\tools\\acme-mcp-server.exe', []);

View File

@@ -53,6 +53,31 @@ const hasPythonModuleArg = (args: string[]): boolean => {
return moduleFlagIndex >= 0 && Boolean(args[moduleFlagIndex + 1]);
};
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
hasArg(args, '-i') || hasArg(args, '--interactive');
const hasDockerImageArg = (args: string[]): boolean => {
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'].includes(lower)) {
index += 1;
}
continue;
}
if (arg.includes('=') || arg.includes(':') || arg.includes('/')) {
return true;
}
}
return false;
};
const buildStep = (
key: string,
label: string,
@@ -149,6 +174,24 @@ export const buildMCPArgumentHintProfile = (
};
}
if (commandName === 'docker') {
const steps = [
buildStep('run', '运行子命令', 'run', 'Docker MCP 通常要以 docker run 启动容器。', true, hasDockerRunArg(normalizedArgs)),
buildStep('interactive', '保持标准输入', '-i', 'MCP 需要 stdio 持续连接Docker 容器必须保留 stdin。', true, hasDockerInteractiveArg(normalizedArgs)),
buildStep('cleanup', '退出后清理容器', '--rm', '测试和日常使用建议自动删除临时容器,避免残留。', false, hasArg(normalizedArgs, '--rm')),
buildStep('image', '镜像名', 'mcp/server-fetch:latest', 'README 里的 Docker 镜像名,放在 docker run 选项之后。', true, hasDockerImageArg(normalizedArgs)),
buildStep('container-env', '容器环境变量', '-e API_KEY=...', '容器内应用需要的 token 通常要用 -e/--env 传给容器。', false, normalizedArgs.some((arg) => arg === '-e' || arg === '--env' || arg.startsWith('-e='))),
];
return {
commandName,
title: 'Docker MCP 参数顺序建议',
summary: 'Docker 场景 command 只填 dockerrun、-i、--rm、镜像名和容器参数都放到 args 里。',
orderHint: '推荐顺序run -> --rm -> -i -> -e KEY=VALUE -> 镜像名 -> 服务自己的业务参数',
steps,
nextActions: buildNextActions(steps),
};
}
const steps = [
buildStep('stdio', 'stdio 模式参数', 'stdio 或 --stdio', '多数本机 MCP 二进制需要显式 stdio 参数;以 README 为准。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '二进制自己的配置文件、工作目录、端口或模式参数。', false, normalizedArgs.length > 0),

View File

@@ -30,6 +30,7 @@ export const MCP_COMMAND_EXAMPLES = [
'uvx mcp-server-fetch',
'node server.js --stdio',
'python -m your_mcp_server',
'docker run --rm -i mcp/server-fetch:latest',
];
export const MCP_COMMAND_PARSE_EXAMPLE = '$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio';
@@ -38,7 +39,7 @@ export const MCP_SERVER_FILL_STEPS: MCPFillStep[] = [
{ step: '1', title: '模板 / 完整命令', detail: '优先选最接近的模板,或先粘一整行命令让 GoNavi 自动拆分。' },
{ step: '2', title: '服务名称', detail: '命名成 Browser、GitHub、Filesystem 这类一眼能认出的用途名。' },
{ step: '3', title: '启动命令', detail: '这里只填程序名或启动器本身,不要把整行命令塞进去。' },
{ step: '4', title: '命令参数', detail: '把脚本名、模块名和 --stdio 这类参数拆开逐项填写。' },
{ step: '4', title: '命令参数', detail: '把脚本名、模块名、Docker run 参数和 --stdio 这类参数拆开逐项填写。' },
{ step: '5', title: '环境变量 / 超时', detail: '只有在服务确实需要额外配置时再补,不需要可以留空。' },
];
@@ -77,21 +78,21 @@ export const MCP_FIELD_GUIDES: MCPFieldGuide[] = [
key: 'command',
title: '启动命令',
summary: '只填程序名或启动器本身。',
detail: '常见是 npx、node、uvx、python;包名、脚本名和 --stdio 这类内容放到参数里。',
fill: '填 npx、node、uvx、python或某个 exe 的绝对路径。',
detail: '常见是 npx、node、uvx、python、docker包名、脚本名、run、-i 和 --stdio 这类内容放到参数里。',
fill: '填 npx、node、uvx、python、docker,或某个 exe 的绝对路径。',
avoid: '不要填整行命令,例如不要填 npx -y pkg --stdio。',
fieldState: 'required',
example: 'npx / node / uvx / python',
example: 'npx / node / uvx / python / docker',
},
{
key: 'args',
title: '命令参数',
summary: '把脚本名、模块名、开关参数拆开逐项填写。',
detail: '例如 npx -y pkg --stdio要拆成 -y、pkg 和 --stdionode server.js --stdio 要拆成 server.js 和 --stdio。',
fill: '逐项填 -y、包名、脚本名、-m、--stdio 等参数。',
avoid: '不要再填 npx/node/uvx/python也不要把多个参数粘成一个长字符串。',
detail: '例如 npx -y pkg --stdio要拆成 -y、pkg 和 --stdiodocker run --rm -i image 要拆成 run、--rm、-i 和 image。',
fill: '逐项填 -y、包名、脚本名、-m、--stdio、run、--rm、-i、镜像名等参数。',
avoid: '不要再填 npx/node/uvx/python/docker,也不要把多个参数粘成一个长字符串。',
fieldState: 'optional',
example: '-y / @modelcontextprotocol/server-filesystem / --stdio / server.js',
example: '-y / @modelcontextprotocol/server-filesystem / --stdio / server.js / run / --rm / -i / image',
},
{
key: 'env',
@@ -118,6 +119,7 @@ export const MCP_FIELD_GUIDES: MCPFieldGuide[] = [
export const MCP_AUTHORING_NOTES = [
'启动命令只填程序本身,不要把脚本名、模块名和 --stdio 混进去。',
'README 给 npx 示例时command 填 npxargs 逐项填 -y、包名和 --stdio不要把整行 npx 命令放进 command。',
'README 给 Docker 示例时command 填 dockerargs 逐项填 run、--rm、-i、镜像名和容器参数容器内 token 通常用 -e KEY=VALUE 传给容器。',
'如果 README 里只给了一整行命令,优先粘到完整命令框自动拆分;支持 KEY=VALUE、env KEY=VALUE、PowerShell $env:KEY=VALUE; 和 Windows set KEY=VALUE && 这几类前缀环境变量写法。',
'环境变量每行一条 KEY=VALUE不要写 export也不要和启动命令混成一行保存。',
'密钥类环境变量会保存到本机配置,并只在启动 MCP 进程时作为进程环境传入;不要把密钥写进聊天内容。',
@@ -135,9 +137,9 @@ export const MCP_TROUBLESHOOTING_GUIDES: MCPTroubleshootingGuide[] = [
{
key: 'timeout-or-no-tools',
symptom: '测试超时或发现 0 个工具',
likelyCause: '服务启动慢、缺少 stdio 参数,或填成了只支持 HTTP/SSE 的 MCP 服务。',
fix: '先确认这个服务支持 stdio再补齐 --stdio 等参数;启动慢时把超时调到 45 或 60 秒。',
example: 'args=--stdio, timeout=45',
likelyCause: '服务启动慢、缺少 stdio 参数,Docker 容器缺少 -i或填成了只支持 HTTP/SSE 的 MCP 服务。',
fix: '先确认这个服务支持 stdio再补齐 --stdio 或 Docker -i 等参数;启动慢时把超时调到 45 或 60 秒。',
example: 'args=--stdio 或 docker run --rm -i image, timeout=45',
},
{
key: 'auth-failed',

View File

@@ -61,6 +61,19 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
timeoutSeconds: 20,
},
},
{
key: 'docker',
title: 'Docker 镜像',
description: '适合 README 里写着 `docker run -i --rm image` 的容器化 MCP。本机需要已安装 Docker。',
detail: '示例会填成 `docker run --rm -i mcp/server-fetch:latest`;容器内 token 通常用 -e KEY=VALUE 放到参数里。',
seed: {
name: 'Docker MCP',
command: 'docker',
args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'],
env: {},
timeoutSeconds: 45,
},
},
{
key: 'exe',
title: '本机 EXE',

View File

@@ -61,4 +61,36 @@ describe('mcpServerValidation', () => {
expect(validation.canSave).toBe(true);
expect(validation.errorCount).toBe(0);
});
it('warns when docker MCP launch args miss run, stdin, or image', () => {
const validation = validateMCPServerDraft({
name: 'Docker MCP',
transport: 'stdio',
command: 'docker',
args: ['--rm'],
timeoutSeconds: 45,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.warningCount).toBeGreaterThanOrEqual(3);
expect(validation.issues.map((issue) => issue.key)).toContain('docker-run-missing');
expect(validation.issues.map((issue) => issue.key)).toContain('docker-interactive-missing');
expect(validation.issues.map((issue) => issue.key)).toContain('docker-image-missing');
});
it('accepts complete docker MCP launch args without docker-specific warnings', () => {
const validation = validateMCPServerDraft({
name: 'Docker MCP',
transport: 'stdio',
command: 'docker',
args: ['run', '--rm', '-i', '-e', 'API_KEY=...', 'mcp/server-fetch:latest'],
timeoutSeconds: 45,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.canSave).toBe(true);
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-run-missing');
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-interactive-missing');
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-image-missing');
});
});

View File

@@ -33,6 +33,7 @@ const KNOWN_LAUNCHER_COMMANDS = new Set([
'py',
'uv',
'uvx',
'docker',
'go',
'java',
'cmd',
@@ -74,7 +75,33 @@ const argsContainEnvOrShellGlue = (args: string[]): boolean =>
const launcherUsuallyNeedsArgs = (command: string): boolean => {
const firstToken = firstShellToken(command);
return ['node', 'python', 'python3', 'py', 'uvx', 'npx', 'bun', 'deno', 'go', 'java'].includes(firstToken);
return ['node', 'python', 'python3', 'py', 'uvx', 'npx', 'bun', 'deno', 'docker', 'go', 'java'].includes(firstToken);
};
const isDockerCommand = (command: string): boolean =>
firstShellToken(command) === 'docker';
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === '-i' || arg.toLowerCase() === '--interactive');
const hasDockerImageArg = (args: string[]): boolean => {
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'].includes(lower)) {
index += 1;
}
continue;
}
return true;
}
return false;
};
export const validateMCPServerDraft = (
@@ -129,6 +156,33 @@ export const validateMCPServerDraft = (
});
}
if (command && isDockerCommand(command)) {
if (!hasDockerRunArg(args)) {
issues.push({
key: 'docker-run-missing',
severity: 'warning',
title: 'Docker 参数缺少 run',
detail: 'Docker MCP 通常需要 command=dockerargs 里单独填写 run、--rm、-i、镜像名和服务参数。',
});
}
if (!hasDockerInteractiveArg(args)) {
issues.push({
key: 'docker-interactive-missing',
severity: 'warning',
title: 'Docker 参数缺少 -i',
detail: 'MCP 需要持续读取标准输入docker run 场景请加 -i 或 --interactive否则工具发现可能立即断开。',
});
}
if (!hasDockerImageArg(args)) {
issues.push({
key: 'docker-image-missing',
severity: 'warning',
title: 'Docker 参数可能缺少镜像名',
detail: '请在 docker run 选项之后填写 README 提供的镜像名,例如 mcp/server-fetch:latest。',
});
}
}
if (argsContainEnvOrShellGlue(args)) {
issues.push({
key: 'args-contain-env-or-shell-glue',