mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-mcp): 支持 Windows 完整命令自动拆分
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }}>
|
||||
自动拆分到下方字段
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 &&');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 进程时作为进程环境传入;不要把密钥写进聊天内容。',
|
||||
'测试工具发现只会临时启动一次做探测,不会自动保存配置。',
|
||||
|
||||
Reference in New Issue
Block a user