From 1ee8339b5cc331c8793280734718531b0c1cc7d2 Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Tue, 14 Apr 2026 04:08:57 +0000 Subject: [PATCH] feat: merge default MCP config files --- src/config.ts | 72 ++++++++++++++++------------- tests/config.test.ts | 108 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 135 insertions(+), 45 deletions(-) diff --git a/src/config.ts b/src/config.ts index 99a4e25..363d269 100644 --- a/src/config.ts +++ b/src/config.ts @@ -379,6 +379,15 @@ function substituteEnvVarsInObject(obj: T): T { /** * Get default config search paths */ +function mergeConfigs(configs: McpServersConfig[]): McpServersConfig { + return { + mcpServers: Object.assign( + {}, + ...configs.map((config) => config.mcpServers), + ), + }; +} + function getDefaultConfigPaths(): string[] { const paths: string[] = []; const home = homedir(); @@ -399,53 +408,54 @@ function getDefaultConfigPaths(): string[] { export async function loadConfig( explicitPath?: string, ): Promise { - let configPath: string | undefined; + let configPaths: string[] = []; // Check explicit path from argument or environment if (explicitPath) { - configPath = resolve(explicitPath); + const configPath = resolve(explicitPath); + if (!existsSync(configPath)) { + throw new Error(formatCliError(configNotFoundError(configPath))); + } + configPaths = [configPath]; } else if (process.env.MCP_CONFIG_PATH) { - configPath = resolve(process.env.MCP_CONFIG_PATH); - } - - // If explicit path provided, it must exist - if (configPath) { + const configPath = resolve(process.env.MCP_CONFIG_PATH); if (!existsSync(configPath)) { throw new Error(formatCliError(configNotFoundError(configPath))); } + configPaths = [configPath]; } else { - // Search default paths - const searchPaths = getDefaultConfigPaths(); - for (const path of searchPaths) { - if (existsSync(path)) { - configPath = path; - break; - } - } - - if (!configPath) { + configPaths = getDefaultConfigPaths().filter((path) => existsSync(path)); + if (configPaths.length === 0) { throw new Error(formatCliError(configSearchError())); } } - // Read and parse config - const file = Bun.file(configPath); - const content = await file.text(); + const parsedConfigs: McpServersConfig[] = []; + for (const configPath of configPaths) { + const file = Bun.file(configPath); + const content = await file.text(); - let config: McpServersConfig; - try { - config = JSON.parse(content); - } catch (e) { - throw new Error( - formatCliError(configInvalidJsonError(configPath, (e as Error).message)), - ); - } + let config: McpServersConfig; + try { + config = JSON.parse(content); + } catch (e) { + throw new Error( + formatCliError( + configInvalidJsonError(configPath, (e as Error).message), + ), + ); + } + + // Validate structure + if (!config.mcpServers || typeof config.mcpServers !== 'object') { + throw new Error(formatCliError(configMissingFieldError(configPath))); + } - // Validate structure - if (!config.mcpServers || typeof config.mcpServers !== 'object') { - throw new Error(formatCliError(configMissingFieldError(configPath))); + parsedConfigs.push(config); } + let config = mergeConfigs(parsedConfigs); + // Warn if no servers are configured if (Object.keys(config.mcpServers).length === 0) { console.error( diff --git a/tests/config.test.ts b/tests/config.test.ts index a48602c..a45b742 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -34,7 +34,7 @@ describe('config', () => { mcpServers: { test: { command: 'echo', args: ['hello'] }, }, - }) + }), ); const config = await loadConfig(configPath); @@ -42,6 +42,80 @@ describe('config', () => { expect((config.mcpServers.test as any).command).toBe('echo'); }); + test('merges default config files from home and current directory', async () => { + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + const workspaceDir = join(tempDir, 'workspace'); + const homeDir = join(tempDir, 'home'); + await mkdir(join(homeDir, '.config', 'mcp'), { recursive: true }); + await mkdir(workspaceDir, { recursive: true }); + await writeFile( + join(homeDir, '.config', 'mcp', 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + personal: { command: 'todoist-mcp' }, + shared: { command: 'personal-gh' }, + }, + }), + ); + await writeFile( + join(workspaceDir, 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + project: { command: 'playwright-mcp' }, + shared: { command: 'company-gh' }, + }, + }), + ); + process.env.HOME = homeDir; + process.chdir(workspaceDir); + + const config = await loadConfig(); + expect(Object.keys(config.mcpServers).sort()).toEqual([ + 'personal', + 'project', + 'shared', + ]); + expect((config.mcpServers.shared as any).command).toBe('company-gh'); + + process.chdir(originalCwd); + process.env.HOME = originalHome; + }); + + test('does not merge defaults when explicit path is provided', async () => { + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + const workspaceDir = join(tempDir, 'workspace-explicit'); + const homeDir = join(tempDir, 'home-explicit'); + await mkdir(join(homeDir, '.config', 'mcp'), { recursive: true }); + await mkdir(workspaceDir, { recursive: true }); + await writeFile( + join(homeDir, '.config', 'mcp', 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + personal: { command: 'todoist-mcp' }, + }, + }), + ); + const explicitPath = join(workspaceDir, 'project.json'); + await writeFile( + explicitPath, + JSON.stringify({ + mcpServers: { + project: { command: 'playwright-mcp' }, + }, + }), + ); + process.env.HOME = homeDir; + process.chdir(workspaceDir); + + const config = await loadConfig(explicitPath); + expect(Object.keys(config.mcpServers)).toEqual(['project']); + + process.chdir(originalCwd); + process.env.HOME = originalHome; + }); + test('throws on missing config file', async () => { const configPath = join(tempDir, 'nonexistent.json'); await expect(loadConfig(configPath)).rejects.toThrow('not found'); @@ -74,7 +148,7 @@ describe('config', () => { headers: { Authorization: 'Bearer ${TEST_MCP_TOKEN}' }, }, }, - }) + }), ); const config = await loadConfig(configPath); @@ -98,7 +172,7 @@ describe('config', () => { env: { TOKEN: '${NONEXISTENT_VAR}' }, }, }, - }) + }), ); const config = await loadConfig(configPath); @@ -122,7 +196,7 @@ describe('config', () => { env: { TOKEN: '${ANOTHER_NONEXISTENT_VAR}' }, }, }, - }) + }), ); await expect(loadConfig(configPath)).rejects.toThrow('MISSING_ENV_VAR'); @@ -136,10 +210,12 @@ describe('config', () => { mcpServers: { badserver: {}, }, - }) + }), ); - await expect(loadConfig(configPath)).rejects.toThrow('missing required field'); + await expect(loadConfig(configPath)).rejects.toThrow( + 'missing required field', + ); }); test('throws error on server with both command and url', async () => { @@ -153,10 +229,12 @@ describe('config', () => { url: 'https://example.com', }, }, - }) + }), ); - await expect(loadConfig(configPath)).rejects.toThrow('both "command" and "url"'); + await expect(loadConfig(configPath)).rejects.toThrow( + 'both "command" and "url"', + ); }); test('throws error on null server config', async () => { @@ -167,10 +245,12 @@ describe('config', () => { mcpServers: { nullserver: null, }, - }) + }), ); - await expect(loadConfig(configPath)).rejects.toThrow('Invalid server configuration'); + await expect(loadConfig(configPath)).rejects.toThrow( + 'Invalid server configuration', + ); }); }); @@ -184,7 +264,7 @@ describe('config', () => { server1: { command: 'cmd1' }, server2: { command: 'cmd2' }, }, - }) + }), ); const config = await loadConfig(configPath); @@ -198,7 +278,7 @@ describe('config', () => { configPath, JSON.stringify({ mcpServers: { known: { command: 'cmd' } }, - }) + }), ); const config = await loadConfig(configPath); @@ -217,7 +297,7 @@ describe('config', () => { beta: { command: 'b' }, gamma: { url: 'https://example.com' }, }, - }) + }), ); const config = await loadConfig(configPath);