Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-command-line.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
44 changes: 38 additions & 6 deletions apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -38,7 +70,7 @@ describe('MCP editor validation', () => {
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
command: '',
commandLine: '',
url: 'file:///tmp/server',
}),
{ url: 'invalid-url' },
Expand All @@ -47,7 +79,7 @@ describe('MCP editor validation', () => {
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
command: '',
commandLine: '',
url: 'https://example.com/mcp',
}),
{},
Expand Down
18 changes: 10 additions & 8 deletions apps/desktop/src/renderer/locales/mcp-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
Expand Down Expand Up @@ -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',
},
},
Expand Down Expand Up @@ -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',
},
},
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/renderer/mcp-command-line.ts
Original file line number Diff line number Diff line change
@@ -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}`)}"`;
}
18 changes: 14 additions & 4 deletions apps/desktop/src/renderer/mcp-editor-validation.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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;
}

Expand Down
23 changes: 13 additions & 10 deletions apps/desktop/src/renderer/mcp-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -648,7 +648,7 @@ export function McpPage(props: { hubHeader?: ModuleHubHeader }) {
}
if (
changedKey !== 'id' &&
changedKey !== 'command' &&
changedKey !== 'commandLine' &&
changedKey !== 'url'
) {
return current;
Expand Down Expand Up @@ -937,14 +937,13 @@ function McpEditorDialog(props: {
<div className="maka-mcp-primary-fields">
<TextInput hasAutoFocus={!editing} label={props.copy.editor.serverId} value={props.state.draft.id} onChange={(value) => updateDraft('id', value)} isDisabled={editing} isRequired placeholder="filesystem" status={props.errors.id ? { type: 'error', message: props.copy.editor.required } : undefined} />
{props.state.draft.kind === 'stdio' ? (
<TextInput hasAutoFocus={editing} label={props.copy.editor.command} value={props.state.draft.command} onChange={(value) => updateDraft('command', value)} isRequired placeholder="npx" status={props.errors.command ? { type: 'error', message: props.copy.editor.required } : undefined} />
<TextInput hasAutoFocus={editing} label={props.copy.editor.command} description={props.copy.editor.commandHelp} value={props.state.draft.commandLine} onChange={(value) => updateDraft('commandLine', value)} isRequired placeholder={props.copy.editor.commandPlaceholder} status={props.errors.commandLine ? { type: 'error', message: props.errors.commandLine === 'unbalanced-quote' ? props.copy.editor.unbalancedQuote : props.copy.editor.required } : undefined} />
) : (
<TextInput hasAutoFocus={editing} label={props.copy.editor.url} value={props.state.draft.url} onChange={(value) => updateDraft('url', value)} isRequired placeholder="https://example.com/mcp" status={props.errors.url ? { type: 'error', message: props.errors.url === 'required' ? props.copy.editor.required : props.copy.editor.invalidUrl } : undefined} />
)}
</div>
{props.state.draft.kind === 'stdio' ? (
<>
<TextArea label={props.copy.editor.arguments} description={props.copy.editor.argumentsHelp} value={props.state.draft.args} onChange={(value) => updateDraft('args', value)} placeholder={props.copy.editor.argumentsPlaceholder} />
<TextArea label={props.copy.editor.environment} description={props.copy.editor.environmentHelp} value={props.state.draft.env} onChange={(value) => updateDraft('env', value)} placeholder={'KEY=value\nTOKEN=secret'} />
<TextInput label={props.copy.editor.workingDirectory} value={props.state.draft.cwd} onChange={(value) => updateDraft('cwd', value)} placeholder={props.copy.editor.workingDirectoryPlaceholder} />
</>
Expand Down Expand Up @@ -978,22 +977,26 @@ function McpEditorDialog(props: {
}

function emptyDraft(): Draft {
return { id: '', kind: 'stdio', enabled: true, command: '', args: '', cwd: '', env: '', url: '', transport: 'auto', headers: '' };
return { id: '', kind: 'stdio', enabled: true, commandLine: '', cwd: '', env: '', url: '', transport: 'auto', headers: '' };
}

function draftFromConfig(id: string, config: McpServerConfig): Draft {
if (isMcpStdioConfig(config)) {
return { ...emptyDraft(), id, enabled: config.enabled !== false, command: config.command, args: (config.args ?? []).join('\n'), cwd: config.cwd ?? '', env: formatMap(config.env) };
return { ...emptyDraft(), id, enabled: config.enabled !== false, commandLine: formatCommandLine(config.command, config.args ?? []), cwd: config.cwd ?? '', env: formatMap(config.env) };
}
return { ...emptyDraft(), id, kind: 'remote', enabled: config.enabled !== false, url: config.url, transport: config.transport ?? 'auto', headers: formatMap(config.headers) };
}

function configFromDraft(draft: Draft, copy: McpCopy): McpServerConfig {
if (draft.kind === 'stdio') {
const parsed = parseCommandLine(draft.commandLine);
// Validation runs before save, so an unbalanced quote cannot reach here
// through the dialog; the throw keeps other callers honest.
if (!parsed.ok) throw new Error(copy.editor.unbalancedQuote);
return {
enabled: draft.enabled,
command: draft.command.trim(),
args: draft.args.split(/\r?\n/u).filter((line) => line.length > 0),
command: parsed.command,
args: parsed.args,
...(draft.cwd.trim() ? { cwd: draft.cwd.trim() } : {}),
env: parseMap(draft.env, copy),
};
Expand All @@ -1014,7 +1017,7 @@ function formatMap(value?: Record<string, string>): string {
}

function endpointFor(server: McpServerConfig): string {
return isMcpStdioConfig(server) ? [server.command, ...(server.args ?? [])].join(' ') : server.url;
return isMcpStdioConfig(server) ? formatCommandLine(server.command, server.args ?? []) : server.url;
}

function replaceStatus(statuses: McpServerStatus[], next: McpServerStatus): McpServerStatus[] {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/module-pages/mcp.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@
.maka-mcp-primary-fields {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
/* The second column holds a full command line or URL; the first is a short
slug. Equal halves squeezed the field that actually carries content. */
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--space-3);
}

Expand Down
Loading