feat(ai-mcp): 支持 Windows 完整命令自动拆分

This commit is contained in:
Syngnat
2026-06-09 18:44:39 +08:00
parent af51ead948
commit 327a78f1cb
6 changed files with 122 additions and 18 deletions

View File

@@ -48,6 +48,8 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('固定');
expect(markup).toContain('直接粘贴完整命令');
expect(markup).toContain('自动拆分到下方字段');
expect(markup).toContain('$env:KEY=VALUE;');
expect(markup).toContain('set KEY=VALUE &&');
expect(markup).toContain('每个参数单独录入一个标签');
expect(markup).toContain('每行一个 KEY=VALUE');
expect(markup).toContain('没有等号或 key 含空格的行不会保存');
@@ -63,6 +65,6 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('稍宽松 45 秒');
expect(markup).toContain('慢启动 60 秒');
expect(markup).toContain('node server.js --stdio');
expect(markup).toContain('OPENAI_API_KEY=... uvx mcp-server-fetch --stdio');
expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio');
});
});

View File

@@ -157,7 +157,7 @@ const AIMCPServerGuidePanel: React.FC<AIMCPServerGuidePanelProps> = ({
<div style={{ padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
GoNavi / / README
GoNavi / / Unix KEY=VALUE Windows PowerShell $env:KEY=VALUE; cmd set KEY=VALUE &&
</div>
<Input.TextArea
rows={2}
@@ -172,7 +172,7 @@ const AIMCPServerGuidePanel: React.FC<AIMCPServerGuidePanelProps> = ({
? parsedCommandDraft.ok && parsedCommandDraft.draft
? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。`
: parsedCommandDraft.error
: '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'}
: '支持带引号路径、带空格参数,以及 KEY=VALUE / $env:KEY=VALUE; / set KEY=VALUE && 环境变量前缀。'}
</div>
<Button onClick={onApplyCommandDraft} disabled={!parsedCommandDraft.ok} style={{ borderRadius: 10 }}>

View File

@@ -31,6 +31,45 @@ describe('mcpCommandDraft helpers', () => {
});
});
it('parses PowerShell env prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('$env:GITHUB_TOKEN="ghp test"; uvx mcp-server-github --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
env: {
GITHUB_TOKEN: 'ghp test',
},
});
});
it('parses Windows cmd set prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('set GITHUB_TOKEN=ghp_test && node server.js --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'node',
args: ['server.js', '--stdio'],
env: {
GITHUB_TOKEN: 'ghp_test',
},
});
});
it('parses env launcher style prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('env OPENAI_API_KEY=sk-test uvx mcp-server-fetch --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'uvx',
args: ['mcp-server-fetch', '--stdio'],
env: {
OPENAI_API_KEY: 'sk-test',
},
});
});
it('reports unclosed quotes instead of producing a broken parse', () => {
expect(splitShellLikeCommand('uvx "broken command')).toEqual({
tokens: ['uvx'],

View File

@@ -11,6 +11,7 @@ export interface ParseMCPCommandDraftResult {
}
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u;
const POWERSHELL_ENV_ASSIGNMENT_RE = /^\$env:([A-Za-z_][A-Za-z0-9_]*)=(.*)$/iu;
const pushToken = (tokens: string[], current: string) => {
if (current) {
@@ -53,6 +54,19 @@ export const splitShellLikeCommand = (input: string): { tokens: string[]; error?
continue;
}
if (char === ';') {
pushToken(tokens, current);
current = '';
continue;
}
if (char === '&' && text[index + 1] === '&') {
pushToken(tokens, current);
current = '';
index += 1;
continue;
}
if (/\s/u.test(char)) {
pushToken(tokens, current);
current = '';
@@ -82,6 +96,61 @@ export const splitShellLikeCommand = (input: string): { tokens: string[]; error?
return { tokens };
};
const consumeEnvAssignmentToken = (token: string, env: Record<string, string>): boolean => {
const text = String(token || '').trim();
if (!text) return false;
const powershellMatch = text.match(POWERSHELL_ENV_ASSIGNMENT_RE);
if (powershellMatch) {
env[powershellMatch[1]] = powershellMatch[2] || '';
return true;
}
if (!ENV_ASSIGNMENT_RE.test(text)) return false;
const separatorIndex = text.indexOf('=');
const key = text.slice(0, separatorIndex).trim();
if (!key) return false;
env[key] = text.slice(separatorIndex + 1);
return true;
};
const isEnvAssignmentToken = (token: string): boolean => {
const text = String(token || '').trim();
return Boolean(text.match(POWERSHELL_ENV_ASSIGNMENT_RE)) || ENV_ASSIGNMENT_RE.test(text);
};
const consumeLeadingEnvAssignments = (tokens: string[], env: Record<string, string>): number => {
let commandIndex = 0;
while (commandIndex < tokens.length) {
const token = tokens[commandIndex];
const normalizedToken = String(token || '').trim().toLowerCase();
if (normalizedToken === 'set' && tokens[commandIndex + 1] && isEnvAssignmentToken(tokens[commandIndex + 1])) {
consumeEnvAssignmentToken(tokens[commandIndex + 1], env);
commandIndex += 2;
continue;
}
if (normalizedToken === 'env' && tokens[commandIndex + 1] && isEnvAssignmentToken(tokens[commandIndex + 1])) {
commandIndex += 1;
while (commandIndex < tokens.length && consumeEnvAssignmentToken(tokens[commandIndex], env)) {
commandIndex += 1;
}
continue;
}
if (consumeEnvAssignmentToken(token, env)) {
commandIndex += 1;
continue;
}
break;
}
return commandIndex;
};
export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => {
const { tokens, error } = splitShellLikeCommand(input);
if (error) {
@@ -92,17 +161,7 @@ export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult
}
const env: Record<string, string> = {};
let commandIndex = 0;
while (commandIndex < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[commandIndex])) {
const token = tokens[commandIndex];
const separatorIndex = token.indexOf('=');
const key = token.slice(0, separatorIndex).trim();
if (key) {
env[key] = token.slice(separatorIndex + 1);
}
commandIndex += 1;
}
const commandIndex = consumeLeadingEnvAssignments(tokens, env);
const command = String(tokens[commandIndex] || '').trim();
if (!command) {

View File

@@ -20,7 +20,11 @@ describe('mcpServerGuidance', () => {
});
it('warns users to keep secrets in local env config instead of chat content', () => {
expect(MCP_AUTHORING_NOTES.join('\n')).toContain('本机配置');
expect(MCP_AUTHORING_NOTES.join('\n')).toContain('不要把密钥写进聊天内容');
const notes = MCP_AUTHORING_NOTES.join('\n');
expect(notes).toContain('本机配置');
expect(notes).toContain('不要把密钥写进聊天内容');
expect(notes).toContain('PowerShell $env:KEY=VALUE;');
expect(notes).toContain('Windows set KEY=VALUE &&');
});
});

View File

@@ -29,7 +29,7 @@ export const MCP_COMMAND_EXAMPLES = [
'python -m your_mcp_server',
];
export const MCP_COMMAND_PARSE_EXAMPLE = 'OPENAI_API_KEY=... uvx mcp-server-fetch --stdio';
export const MCP_COMMAND_PARSE_EXAMPLE = '$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio';
export const MCP_SERVER_FILL_STEPS: MCPFillStep[] = [
{ step: '1', title: '模板 / 完整命令', detail: '优先选最接近的模板,或先粘一整行命令让 GoNavi 自动拆分。' },
@@ -100,7 +100,7 @@ export const MCP_FIELD_GUIDES: MCPFieldGuide[] = [
export const MCP_AUTHORING_NOTES = [
'启动命令只填程序本身,不要把脚本名、模块名和 --stdio 混进去。',
'如果 README 里只给了一整行命令,优先粘到完整命令框自动拆分。',
'如果 README 里只给了一整行命令,优先粘到完整命令框自动拆分;支持 KEY=VALUE、env KEY=VALUE、PowerShell $env:KEY=VALUE; 和 Windows set KEY=VALUE && 这几类前缀环境变量写法。',
'环境变量每行一条 KEY=VALUE不要写 export也不要和启动命令混成一行保存。',
'密钥类环境变量会保存到本机配置,并只在启动 MCP 进程时作为进程环境传入;不要把密钥写进聊天内容。',
'测试工具发现只会临时启动一次做探测,不会自动保存配置。',