enhance: mcp config page (#339)

This commit is contained in:
Wendong-Fan 2025-09-16 15:33:04 +08:00 committed by GitHub
commit cb9d673a7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 348 additions and 21 deletions

View file

@ -534,6 +534,15 @@ function registerIpcHandlers() {
// ==================== MCP manage handler ====================
ipcMain.handle('mcp-install', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
addMcp(name, mcp);
return { success: true };
});
@ -544,6 +553,15 @@ function registerIpcHandlers() {
});
ipcMain.handle('mcp-update', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
updateMcp(name, mcp);
return { success: true };
});

View file

@ -8,6 +8,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp.json');
type McpServerConfig = {
command: string;
args: string[];
description?: string;
env?: Record<string, string>;
} | {
url: string;
@ -17,7 +18,7 @@ type McpServersConfig = {
[name: string]: McpServerConfig;
};
type ConfigFile = {
export type ConfigFile = {
mcpServers: McpServersConfig;
};
@ -42,6 +43,28 @@ export function readMcpConfig(): ConfigFile {
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
return getDefaultConfig();
}
// Normalize args field - ensure it's always an array
Object.keys(parsed.mcpServers).forEach(serverName => {
const server = parsed.mcpServers[serverName];
if (server.args) {
const args = server.args as any;
if (typeof args === 'string') {
try {
// Try to parse as JSON string first
server.args = JSON.parse(args);
} catch (e) {
// If parsing fails, split by comma as fallback
server.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
// Ensure it's always an array of strings
if (Array.isArray(server.args)) {
server.args = server.args.map((arg: any) => String(arg));
}
}
});
return parsed;
} catch (e) {
return getDefaultConfig();
@ -58,7 +81,22 @@ export function writeMcpConfig(config: ConfigFile): void {
export function addMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
if (!config.mcpServers[name]) {
config.mcpServers[name] = mcp;
// Ensure args is an array before adding
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}
}
@ -74,6 +112,21 @@ export function removeMcp(name: string): void {
export function updateMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
config.mcpServers[name] = mcp;
// Ensure args is an array before updating
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}

View file

@ -20,6 +20,7 @@ import { getProxyBaseURL } from "@/lib";
import { useAuthStore } from "@/store/authStore";
import { toast } from "sonner";
import { ConfigFile } from "electron/main/utils/mcpConfig";
export default function SettingMCP() {
const navigate = useNavigate();
@ -195,13 +196,28 @@ export default function SettingMCP() {
setSaving(true);
setErrorMsg(null);
try {
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, {
const mcpData = {
mcp_name: configForm.mcp_name,
mcp_desc: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
env: configForm.env,
});
}
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData);
if (window.ipcRenderer) {
//Partial payload to empty env {}
const payload: any = {
description: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
};
if (configForm.env && Object.keys(configForm.env).length > 0) {
payload.env = configForm.env;
}
window.ipcRenderer.invoke("mcp-update", mcpData.mcp_name, payload);
}
setShowConfig(null);
fetchList();
} catch (err: any) {
@ -236,9 +252,27 @@ export default function SettingMCP() {
setInstalling(true);
try {
if (addType === "local") {
let data;
let data:ConfigFile;
try {
data = JSON.parse(localJson);
// validate mcpServers structure
if (!data.mcpServers || typeof data.mcpServers !== "object") {
throw new Error("Invalid mcpServers");
}
// check for name conflicts with existing items
const serverNames = Object.keys(data.mcpServers);
const conflict = serverNames.find((name) =>
items.some((d) => d.mcp_name === name)
);
if (conflict) {
toast.error(`MCP server "${conflict}" already exists`, {
closeButton: true,
});
setInstalling(false);
return;
}
} catch (e) {
toast.error("Invalid JSON", { closeButton: true });
setInstalling(false);
@ -252,19 +286,14 @@ export default function SettingMCP() {
}
if (window.ipcRenderer) {
const mcpServers = data["mcpServers"];
Object.entries(mcpServers).forEach(async ([key, value]) => {
for (const [key, value] of Object.entries(mcpServers)) {
await window.ipcRenderer.invoke("mcp-install", key, value);
});
}
}
}
setShowAdd(false);
setLocalJson(`{
"mcp_id": 0,
"mcp_name": "",
"mcp_desc": "",
"command": "",
"args": "",
"env": {}
"mcpServers": {}
}`);
setRemoteName("");
setRemoteUrl("");
@ -335,13 +364,13 @@ export default function SettingMCP() {
{!isLoading && !error && items.length === 0 && (
<div className="text-center py-8 text-gray-400">No MCP servers</div>
)}
<MCPList
{!isLoading && <MCPList
items={items}
onSetting={setShowConfig}
onDelete={setDeleteTarget}
onSwitch={handleSwitch}
switchLoading={switchLoading}
/>
/>}
<MCPConfigDialog
open={!!showConfig}
form={configForm}

View file

@ -34,7 +34,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
<form onSubmit={onSave} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled={loading} />
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled readOnly />
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
@ -45,7 +45,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.command} onChange={e => onChange({ ...form, command: e.target.value })} disabled={loading} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Args (one per line)</label>
<label className="block text-sm font-medium mb-1">Args (one per line, no quotes or commas)</label>
<textarea
autoComplete="off"
className="w-full border rounded px-3 py-2 text-sm"

View file

@ -12,9 +12,9 @@ interface MCPListProps {
export default function MCPList({ items, onSetting, onDelete, onSwitch, switchLoading }: MCPListProps) {
return (
<div className='pt-4'>
{items.map(item => (
{items.map((item) => (
<MCPListItem
key={item.mcp_id}
key={item.id}
item={item}
onSetting={onSetting}
onDelete={onDelete}

View file

@ -1,11 +1,37 @@
export function parseArgsToArray(args: string): string[] {
try {
// Try parsing as JSON array first
const arr = JSON.parse(args);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// Handle malformed JSON by manually trimming { } and trying again
if (args.trim().startsWith('{') && args.trim().endsWith('}')) {
const trimmed = args.trim().slice(1, -1); // Remove { }
try {
// Try parsing the trimmed version as JSON array
const arr = JSON.parse(`[${trimmed}]`);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// If still fails, treat as comma-separated
if (trimmed.trim()) {
return trimmed.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
}
// If not JSON, treat as comma-separated string
if (args.trim()) {
return args.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
return [];
}
export function arrayToArgsJson(arr: string[]): string {
return JSON.stringify(arr.filter(v => v.trim() !== ''));
const filtered = arr.filter(v => v.trim() !== '');
if (filtered.length === 0) return '';
// Return as JSON stringified array
return JSON.stringify(filtered);
}

View file

@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import { parseArgsToArray, arrayToArgsJson } from '../../../../src/pages/Setting/components/utils';
describe('parseArgsToArray', () => {
it('should parse JSON array string to array', () => {
const input = '["arg1", "arg2", "arg3"]';
const expected = ['arg1', 'arg2', 'arg3'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with special characters', () => {
const input = '["-y", "@modelcontextprotocol/server-sequential-thinking"]';
const expected = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing backslashes', () => {
const input = '["--directory", "C:\\\\Users\\\\ASUS\\\\Desktop\\\\project", "run", "main.py"]';
const expected = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing forward slashes', () => {
const input = '["--directory", "C:/Users/ASUS/Desktop/project", "run", "main.py"]';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string to array', () => {
const input = '-y,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with spaces', () => {
const input = '-y, @modelcontextprotocol/server-filesystem, .';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with file paths containing slashes', () => {
const input = '--directory,C:/Users/ASUS/Desktop/project,run,main.py';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle empty string', () => {
const input = '';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle whitespace-only string', () => {
const input = ' ';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should filter out empty args from comma-separated string', () => {
const input = '-y,,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle invalid JSON gracefully by treating as comma-separated', () => {
const input = '[invalid json';
const expected: string[] = ['[invalid json'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle non-array JSON by treating as comma-separated', () => {
const input = '{"key": "value"}';
//Trim the curly braces
const expected: string[] = ['"key": "value"'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should convert array elements to strings', () => {
const input = '[123, true, "string", null]';
const expected = ['123', 'true', 'string', 'null'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
});
describe('arrayToArgsJson', () => {
it('should convert array to JSON string', () => {
const input = ['arg1', 'arg2', 'arg3'];
const expected = '["arg1","arg2","arg3"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with special characters to JSON string', () => {
const input = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const expected = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing backslashes', () => {
const input = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const expected = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing forward slashes', () => {
const input = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const expected = '["--directory","C:/Users/ASUS/Desktop/project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should handle empty array', () => {
const input: string[] = [];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should filter out empty strings and whitespace-only strings', () => {
const input = ['arg1', '', ' ', 'arg2'];
const expected = '["arg1","arg2"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should return empty string for array with only empty/whitespace strings', () => {
const input = ['', ' ', '\t', '\n'];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should preserve strings with meaningful whitespace', () => {
const input = ['arg with spaces', 'another arg'];
const expected = '["arg with spaces","another arg"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
});
describe('bidirectional conversion', () => {
it('should correctly convert from comma-separated string to JSON and back', () => {
const original = '-y,@modelcontextprotocol/server-filesystem,.';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-filesystem', '.']);
expect(jsonString).toBe('["-y","@modelcontextprotocol/server-filesystem","."]');
expect(finalArray).toEqual(array);
});
it('should correctly convert from JSON string to array and back', () => {
const original = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-sequential-thinking']);
expect(jsonString).toBe(original);
});
it('should handle file paths with various slash types bidirectionally', () => {
const windowsPath = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run"]';
const unixPath = '["--directory","/home/user/project","run"]';
// Test Windows paths
const windowsArray = parseArgsToArray(windowsPath);
const windowsJson = arrayToArgsJson(windowsArray);
expect(parseArgsToArray(windowsJson)).toEqual(windowsArray);
// Test Unix paths
const unixArray = parseArgsToArray(unixPath);
const unixJson = arrayToArgsJson(unixArray);
expect(parseArgsToArray(unixJson)).toEqual(unixArray);
});
it('should handle mixed path separators in comma-separated format', () => {
const mixed = '--directory,C:/Users/ASUS\\Desktop/project,run,main.py';
const array = parseArgsToArray(mixed);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['--directory', 'C:/Users/ASUS\\Desktop/project', 'run', 'main.py']);
expect(finalArray).toEqual(array);
});
});