mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-08 15:43:42 +08:00
✨ feat(ai-mcp): 支持 Windows 完整命令自动拆分
This commit is contained in:
@@ -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