diff --git a/apps/desktop/src/main/__tests__/mcp-command-line.test.ts b/apps/desktop/src/main/__tests__/mcp-command-line.test.ts new file mode 100644 index 0000000000..615e6af7ad --- /dev/null +++ b/apps/desktop/src/main/__tests__/mcp-command-line.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + formatCommandLine, + parseCommandLine, +} from '../../renderer/mcp-command-line.js'; + +describe('MCP command line parsing', () => { + it('splits on whitespace without shell interpretation', () => { + assert.deepEqual( + parseCommandLine('npx -y @modelcontextprotocol/server-filesystem /path/to/folder'), + { ok: true, command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/folder'] }, + ); + assert.deepEqual(parseCommandLine(' uvx mcp-science timer '), { + ok: true, + command: 'uvx', + args: ['mcp-science', 'timer'], + }); + assert.deepEqual(parseCommandLine(''), { ok: true, command: '', args: [] }); + // $VAR and globs stay literal — this is tokenization, not a shell. + assert.deepEqual(parseCommandLine('echo $HOME *.ts'), { + ok: true, + command: 'echo', + args: ['$HOME', '*.ts'], + }); + }); + + it('groups quoted spans, including mid-token quotes', () => { + assert.deepEqual(parseCommandLine('node "/my server/index.js"'), { + ok: true, + command: 'node', + args: ['/my server/index.js'], + }); + assert.deepEqual(parseCommandLine("npx --dir='/tmp/a b'"), { + ok: true, + command: 'npx', + args: ['--dir=/tmp/a b'], + }); + assert.deepEqual(parseCommandLine('run "say \\"hi\\"" done'), { + ok: true, + command: 'run', + args: ['say "hi"', 'done'], + }); + }); + + it('reports unbalanced quotes instead of guessing', () => { + assert.deepEqual(parseCommandLine('npx "unterminated'), { + ok: false, + error: 'unbalanced-quote', + }); + assert.deepEqual(parseCommandLine("npx 'unterminated"), { + ok: false, + error: 'unbalanced-quote', + }); + }); + + it('round-trips config command and args through format and parse', () => { + const cases: Array<[string, string[]]> = [ + ['npx', ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/folder']], + ['node', ['/my server/index.js', '--label', 'a "quoted" value']], + ['python', ['-c', 'print("x y")', 'C:\\my dir\\server.py']], + ['uvx', []], + ]; + for (const [command, args] of cases) { + const parsed = parseCommandLine(formatCommandLine(command, args)); + assert.deepEqual(parsed, { ok: true, command, args }); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts index db83340676..14282b2904 100644 --- a/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts @@ -8,28 +8,60 @@ describe('MCP editor validation', () => { validateMcpEditorDraft({ id: ' ', kind: 'stdio', - command: '', + commandLine: '', url: '', }), - { id: 'required', command: 'required' }, + { id: 'required', commandLine: 'required' }, ); assert.deepEqual( validateMcpEditorDraft({ id: '', kind: 'remote', - command: '', + commandLine: '', url: ' ', }), { id: 'required', url: 'required' }, ); }); + it('accepts a full command line and rejects unbalanced quotes', () => { + assert.deepEqual( + validateMcpEditorDraft({ + id: 'filesystem', + kind: 'stdio', + commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"', + url: '', + }), + {}, + ); + assert.deepEqual( + validateMcpEditorDraft({ + id: 'filesystem', + kind: 'stdio', + commandLine: 'npx "unterminated', + url: '', + }), + { commandLine: 'unbalanced-quote' }, + ); + // Quotes around nothing still parse; an empty command is missing, not + // malformed. + assert.deepEqual( + validateMcpEditorDraft({ + id: 'filesystem', + kind: 'stdio', + commandLine: '""', + url: '', + }), + { commandLine: 'required' }, + ); + }); + it('accepts only HTTP(S) URLs for remote servers', () => { assert.deepEqual( validateMcpEditorDraft({ id: 'remote', kind: 'remote', - command: '', + commandLine: '', url: 'not a url', }), { url: 'invalid-url' }, @@ -38,7 +70,7 @@ describe('MCP editor validation', () => { validateMcpEditorDraft({ id: 'remote', kind: 'remote', - command: '', + commandLine: '', url: 'file:///tmp/server', }), { url: 'invalid-url' }, @@ -47,7 +79,7 @@ describe('MCP editor validation', () => { validateMcpEditorDraft({ id: 'remote', kind: 'remote', - command: '', + commandLine: '', url: 'https://example.com/mcp', }), {}, diff --git a/apps/desktop/src/renderer/locales/mcp-copy.ts b/apps/desktop/src/renderer/locales/mcp-copy.ts index f2e0f0abac..68f5fa7117 100644 --- a/apps/desktop/src/renderer/locales/mcp-copy.ts +++ b/apps/desktop/src/renderer/locales/mcp-copy.ts @@ -39,10 +39,10 @@ export type McpCopy = { importTitle: string; editTitle(id: string): string; addTitle: string; importSubtitle: string; manualSubtitle: string; modeAria: string; manual: string; pasteJson: string; jsonConfig: string; jsonHelp: string; cancel: string; importConnect: string; transportAria: string; localStdio: string; remoteUrl: string; - serverId: string; command: string; arguments: string; argumentsPlaceholder: string; argumentsHelp: string; + serverId: string; command: string; commandPlaceholder: string; commandHelp: string; workingDirectory: string; workingDirectoryPlaceholder: string; environment: string; environmentHelp: string; url: string; headers: string; headersHelp: string; saveConnect: string; - required: string; invalidUrl: string; + required: string; invalidUrl: string; unbalancedQuote: string; transportLabel: string; transportAuto: string; transportStreamableHttp: string; transportLegacySse: string; }; }; @@ -91,12 +91,13 @@ const MCP_COPY = { manualSubtitle: '配置保存在当前工作区的 mcp.json。', modeAria: 'MCP 添加方式', manual: '手动配置', pasteJson: '粘贴 JSON', jsonConfig: 'JSON 配置', jsonHelp: '支持完整 mcpServers 配置或直接的 server map。未在本次导入中出现的已有 MCP 会保留。', cancel: '取消', importConnect: '导入并连接', transportAria: '连接方式', localStdio: '本地 stdio', remoteUrl: '远程 URL', - serverId: '服务器 ID', command: '命令', arguments: '参数', - argumentsPlaceholder: '每行一个参数\n-y\n@modelcontextprotocol/server-filesystem\n/path/to/folder', argumentsHelp: '每行一个参数,不经过 shell 解析。', + serverId: '服务器 ID', command: '命令', + commandPlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /path/to/folder', + commandHelp: '完整命令行;含空格的参数用引号包裹,不经过 shell 解析。', workingDirectory: '工作目录', workingDirectoryPlaceholder: '可选,例如 /path/to/project', environment: '环境变量', environmentHelp: '每行一个 KEY=value;按 MCP 要求填写。', url: 'MCP URL', headers: 'HTTP 请求头', headersHelp: '每行一个 Header=value。', saveConnect: '保存并连接', - required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', + required: '此字段为必填项。', invalidUrl: '请输入有效的 HTTP 或 HTTPS URL。', unbalancedQuote: '引号未闭合。', transportLabel: '传输协议', transportAuto: '自动回退', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: '旧版 SSE', }, }, @@ -143,12 +144,13 @@ const MCP_COPY = { manualSubtitle: 'Configuration is saved in mcp.json for the current workspace.', modeAria: 'MCP add method', manual: 'Manual configuration', pasteJson: 'Paste JSON', jsonConfig: 'JSON configuration', jsonHelp: 'Supports a complete mcpServers configuration or a server map. Existing MCP servers omitted from this import are preserved.', cancel: 'Cancel', importConnect: 'Import and connect', transportAria: 'Connection method', localStdio: 'Local stdio', remoteUrl: 'Remote URL', - serverId: 'Server ID', command: 'Command', arguments: 'Arguments', - argumentsPlaceholder: 'One argument per line\n-y\n@modelcontextprotocol/server-filesystem\n/path/to/folder', argumentsHelp: 'Each line is a separate argument and does not use shell interpolation.', + serverId: 'Server ID', command: 'Command', + commandPlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /path/to/folder', + commandHelp: 'Full command line; quote arguments containing spaces. Not interpreted by a shell.', workingDirectory: 'Working directory', workingDirectoryPlaceholder: 'Optional, for example /path/to/project', environment: 'Environment', environmentHelp: 'One KEY=value entry per line; complete the variables required by this MCP.', url: 'MCP URL', headers: 'HTTP headers', headersHelp: 'One Header=value entry per line.', saveConnect: 'Save and connect', - required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', + required: 'This field is required.', invalidUrl: 'Enter a valid HTTP or HTTPS URL.', unbalancedQuote: 'Unclosed quote.', transportLabel: 'Transport', transportAuto: 'Auto fallback', transportStreamableHttp: 'Streamable HTTP', transportLegacySse: 'Legacy SSE', }, }, diff --git a/apps/desktop/src/renderer/mcp-command-line.ts b/apps/desktop/src/renderer/mcp-command-line.ts new file mode 100644 index 0000000000..f3f2149530 --- /dev/null +++ b/apps/desktop/src/renderer/mcp-command-line.ts @@ -0,0 +1,65 @@ +// apps/desktop/src/renderer/mcp-command-line.ts +// +// The editor's single 命令 field holds a whole command line; mcp.json keeps +// the protocol shape (`command` + `args[]`). These two functions are the +// bridge, and they are inverses: parse(format(command, args)) always yields +// the same tokens back. +// +// Tokenization is deliberately not a shell: no expansion, no globbing, no +// operators. Whitespace separates tokens; single or double quotes group +// spans containing whitespace; inside double quotes, `\"` and `\\` escape. + +export type ParsedCommandLine = + | { ok: true; command: string; args: string[] } + | { ok: false; error: 'unbalanced-quote' }; + +export function parseCommandLine(input: string): ParsedCommandLine { + const tokens: string[] = []; + let current = ''; + let hasToken = false; + let quote: '"' | "'" | null = null; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (quote === '"' && char === '\\' && (input[index + 1] === '"' || input[index + 1] === '\\')) { + current += input[index + 1]; + index += 1; + continue; + } + if (quote) { + if (char === quote) { + quote = null; + continue; + } + current += char; + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasToken = true; + continue; + } + if (/\s/u.test(char)) { + if (hasToken) { + tokens.push(current); + current = ''; + hasToken = false; + } + continue; + } + current += char; + hasToken = true; + } + if (quote) return { ok: false, error: 'unbalanced-quote' }; + if (hasToken) tokens.push(current); + const [command = '', ...args] = tokens; + return { ok: true, command, args }; +} + +export function formatCommandLine(command: string, args: readonly string[]): string { + return [command, ...args].map(quoteToken).join(' '); +} + +function quoteToken(token: string): string { + if (token !== '' && !/[\s"']/u.test(token)) return token; + return `"${token.replace(/[\\"]/gu, (char) => `\\${char}`)}"`; +} diff --git a/apps/desktop/src/renderer/mcp-editor-validation.ts b/apps/desktop/src/renderer/mcp-editor-validation.ts index 372834ff7f..e35721986b 100644 --- a/apps/desktop/src/renderer/mcp-editor-validation.ts +++ b/apps/desktop/src/renderer/mcp-editor-validation.ts @@ -1,13 +1,18 @@ +import { parseCommandLine } from './mcp-command-line.js'; + export type McpEditorDraft = { id: string; kind: 'stdio' | 'remote'; - command: string; + commandLine: string; url: string; }; -export type McpEditorValidationCode = 'required' | 'invalid-url'; +export type McpEditorValidationCode = + | 'required' + | 'invalid-url' + | 'unbalanced-quote'; export type McpEditorErrors = Partial< - Record<'id' | 'command' | 'url', McpEditorValidationCode> + Record<'id' | 'commandLine' | 'url', McpEditorValidationCode> >; export function validateMcpEditorDraft( @@ -17,7 +22,12 @@ export function validateMcpEditorDraft( if (!draft.id.trim()) errors.id = 'required'; if (draft.kind === 'stdio') { - if (!draft.command.trim()) errors.command = 'required'; + const parsed = parseCommandLine(draft.commandLine); + if (!parsed.ok) { + errors.commandLine = 'unbalanced-quote'; + } else if (!parsed.command.trim()) { + errors.commandLine = 'required'; + } return errors; } diff --git a/apps/desktop/src/renderer/mcp-page.tsx b/apps/desktop/src/renderer/mcp-page.tsx index 3b444a1270..a98d967915 100644 --- a/apps/desktop/src/renderer/mcp-page.tsx +++ b/apps/desktop/src/renderer/mcp-page.tsx @@ -78,6 +78,7 @@ import { McpBrandMark, hasMcpBrandMark } from './mcp-brand-marks'; import { parseMcpImport } from './mcp-import'; import { settingsActionErrorMessage } from './settings/settings-error-copy'; import { getMcpCopy, type McpCopy } from './locales/mcp-copy'; +import { formatCommandLine, parseCommandLine } from './mcp-command-line'; import { validateMcpEditorDraft, type McpEditorErrors, @@ -87,8 +88,7 @@ type Draft = { id: string; kind: 'stdio' | 'remote'; enabled: boolean; - command: string; - args: string; + commandLine: string; cwd: string; env: string; url: string; @@ -648,7 +648,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) { } if ( changedKey !== 'id' && - changedKey !== 'command' && + changedKey !== 'commandLine' && changedKey !== 'url' ) { return current; @@ -937,14 +937,13 @@ function McpEditorDialog(props: {