From 97868e8f25aa588c8341ab12fdbd5ab68a8bf01f Mon Sep 17 00:00:00 2001 From: haosenwang1018 Date: Sat, 18 Apr 2026 07:27:09 +0000 Subject: [PATCH] feat: merge default config files Signed-off-by: haosenwang1018 --- src/config.ts | 91 ++++++++++++++----------- tests/config.test.ts | 156 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 180 insertions(+), 67 deletions(-) diff --git a/src/config.ts b/src/config.ts index 99a4e25..67c9922 100644 --- a/src/config.ts +++ b/src/config.ts @@ -396,39 +396,7 @@ function getDefaultConfigPaths(): string[] { /** * Load and parse MCP servers configuration */ -export async function loadConfig( - explicitPath?: string, -): Promise { - let configPath: string | undefined; - - // Check explicit path from argument or environment - if (explicitPath) { - configPath = resolve(explicitPath); - } else if (process.env.MCP_CONFIG_PATH) { - configPath = resolve(process.env.MCP_CONFIG_PATH); - } - - // If explicit path provided, it must exist - if (configPath) { - if (!existsSync(configPath)) { - throw new Error(formatCliError(configNotFoundError(configPath))); - } - } else { - // Search default paths - const searchPaths = getDefaultConfigPaths(); - for (const path of searchPaths) { - if (existsSync(path)) { - configPath = path; - break; - } - } - - if (!configPath) { - throw new Error(formatCliError(configSearchError())); - } - } - - // Read and parse config +async function parseConfigFile(configPath: string): Promise { const file = Bun.file(configPath); const content = await file.text(); @@ -446,13 +414,6 @@ export async function loadConfig( throw new Error(formatCliError(configMissingFieldError(configPath))); } - // Warn if no servers are configured - if (Object.keys(config.mcpServers).length === 0) { - console.error( - '[mcp-cli] Warning: No servers configured in mcpServers. Add server configurations to use MCP tools.', - ); - } - // Validate individual server configs for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) { if (!serverConfig || typeof serverConfig !== 'object') { @@ -496,6 +457,56 @@ export async function loadConfig( } } + return config; +} + +export async function loadConfig( + explicitPath?: string, +): Promise { + let configPaths: string[] = []; + + // Check explicit path from argument or environment + if (explicitPath) { + configPaths = [resolve(explicitPath)]; + } else if (process.env.MCP_CONFIG_PATH) { + configPaths = [resolve(process.env.MCP_CONFIG_PATH)]; + } else { + // Search default paths and merge all configs that exist. + configPaths = getDefaultConfigPaths().filter((path) => existsSync(path)); + } + + if (configPaths.length === 0) { + throw new Error(formatCliError(configSearchError())); + } + + // Explicit path provided via arg/env must exist. + if ( + (explicitPath || process.env.MCP_CONFIG_PATH) && + !existsSync(configPaths[0]) + ) { + throw new Error(formatCliError(configNotFoundError(configPaths[0]))); + } + + let config: McpServersConfig = { mcpServers: {} }; + + // Merge from lowest priority to highest priority so nearer configs override. + for (const configPath of [...configPaths].reverse()) { + const parsed = await parseConfigFile(configPath); + config = { + mcpServers: { + ...config.mcpServers, + ...parsed.mcpServers, + }, + }; + } + + // Warn if no servers are configured + if (Object.keys(config.mcpServers).length === 0) { + console.error( + '[mcp-cli] Warning: No servers configured in mcpServers. Add server configurations to use MCP tools.', + ); + } + // Substitute environment variables config = substituteEnvVarsInObject(config); diff --git a/tests/config.test.ts b/tests/config.test.ts index a48602c..94323cd 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,16 +2,16 @@ * Unit tests for config module */ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { - loadConfig, getServerConfig, - listServerNames, isHttpServer, isStdioServer, + listServerNames, + loadConfig, } from '../src/config'; describe('config', () => { @@ -34,12 +34,14 @@ describe('config', () => { mcpServers: { test: { command: 'echo', args: ['hello'] }, }, - }) + }), ); const config = await loadConfig(configPath); expect(config.mcpServers.test).toBeDefined(); - expect((config.mcpServers.test as any).command).toBe('echo'); + expect( + isStdioServer(config.mcpServers.test) && config.mcpServers.test.command, + ).toBe('echo'); }); test('throws on missing config file', async () => { @@ -74,14 +76,16 @@ describe('config', () => { headers: { Authorization: 'Bearer ${TEST_MCP_TOKEN}' }, }, }, - }) + }), ); const config = await loadConfig(configPath); - const server = config.mcpServers.test as any; - expect(server.headers.Authorization).toBe('Bearer secret123'); + const server = config.mcpServers.test; + expect(isHttpServer(server) && server.headers?.Authorization).toBe( + 'Bearer secret123', + ); - delete process.env.TEST_MCP_TOKEN; + process.env.TEST_MCP_TOKEN = undefined; }); test('handles missing env vars gracefully with MCP_STRICT_ENV=false', async () => { @@ -98,19 +102,19 @@ describe('config', () => { env: { TOKEN: '${NONEXISTENT_VAR}' }, }, }, - }) + }), ); const config = await loadConfig(configPath); - const server = config.mcpServers.test as any; - expect(server.env.TOKEN).toBe(''); + const server = config.mcpServers.test; + expect(isStdioServer(server) && server.env?.TOKEN).toBe(''); - delete process.env.MCP_STRICT_ENV; + process.env.MCP_STRICT_ENV = undefined; }); test('throws error on missing env vars in strict mode (default)', async () => { // Ensure strict mode is enabled (default) - delete process.env.MCP_STRICT_ENV; + process.env.MCP_STRICT_ENV = undefined; const configPath = join(tempDir, 'missing_env_strict.json'); await writeFile( @@ -122,7 +126,7 @@ describe('config', () => { env: { TOKEN: '${ANOTHER_NONEXISTENT_VAR}' }, }, }, - }) + }), ); await expect(loadConfig(configPath)).rejects.toThrow('MISSING_ENV_VAR'); @@ -136,10 +140,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 +159,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 +175,104 @@ describe('config', () => { mcpServers: { nullserver: null, }, - }) + }), + ); + + await expect(loadConfig(configPath)).rejects.toThrow( + 'Invalid server configuration', ); + }); + + test('merges default config search paths', async () => { + const originalCwd = process.cwd(); + const workspaceDir = join(tempDir, 'workspace'); + const homeConfigPath = join(homedir(), '.mcp_servers.json'); + const backupPath = `${homeConfigPath}.mcp-cli-test-backup`; + await mkdir(workspaceDir, { recursive: true }); + + let hadExistingHomeConfig = false; + try { + process.chdir(workspaceDir); + + if (await Bun.file(homeConfigPath).exists()) { + hadExistingHomeConfig = true; + await writeFile(backupPath, await Bun.file(homeConfigPath).text()); + } + + await writeFile( + homeConfigPath, + JSON.stringify({ + mcpServers: { + personal: { command: 'todoist-mcp' }, + }, + }), + ); + await writeFile( + join(workspaceDir, 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + project: { command: 'playwright-mcp' }, + }, + }), + ); + + const config = await loadConfig(); + expect(Object.keys(config.mcpServers).sort()).toEqual([ + 'personal', + 'project', + ]); + } finally { + process.chdir(originalCwd); + if (hadExistingHomeConfig) { + await writeFile(homeConfigPath, await Bun.file(backupPath).text()); + await rm(backupPath, { force: true }); + } else { + await rm(homeConfigPath, { force: true }); + } + } + }); - await expect(loadConfig(configPath)).rejects.toThrow('Invalid server configuration'); + test('nearer config overrides same-named server from lower priority config', async () => { + const originalCwd = process.cwd(); + const originalHome = process.env.HOME; + const workspaceDir = join(tempDir, 'workspace'); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(join(tempDir, '.config', 'mcp'), { recursive: true }); + + try { + process.chdir(workspaceDir); + process.env.HOME = tempDir; + + await writeFile( + join(tempDir, '.config', 'mcp', 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + github: { command: 'personal-github-mcp' }, + }, + }), + ); + await writeFile( + join(workspaceDir, 'mcp_servers.json'), + JSON.stringify({ + mcpServers: { + github: { command: 'work-github-mcp' }, + }, + }), + ); + + const config = await loadConfig(); + expect( + isStdioServer(config.mcpServers.github) && + config.mcpServers.github.command, + ).toBe('work-github-mcp'); + } finally { + process.chdir(originalCwd); + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + process.env.HOME = undefined; + } + } }); }); @@ -184,12 +286,12 @@ describe('config', () => { server1: { command: 'cmd1' }, server2: { command: 'cmd2' }, }, - }) + }), ); const config = await loadConfig(configPath); const server = getServerConfig(config, 'server1'); - expect((server as any).command).toBe('cmd1'); + expect(isStdioServer(server) && server.command).toBe('cmd1'); }); test('throws on unknown server', async () => { @@ -198,7 +300,7 @@ describe('config', () => { configPath, JSON.stringify({ mcpServers: { known: { command: 'cmd' } }, - }) + }), ); const config = await loadConfig(configPath); @@ -217,7 +319,7 @@ describe('config', () => { beta: { command: 'b' }, gamma: { url: 'https://example.com' }, }, - }) + }), ); const config = await loadConfig(configPath);