Skip to content
Open
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
91 changes: 51 additions & 40 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,39 +396,7 @@ function getDefaultConfigPaths(): string[] {
/**
* Load and parse MCP servers configuration
*/
export async function loadConfig(
explicitPath?: string,
): Promise<McpServersConfig> {
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<McpServersConfig> {
const file = Bun.file(configPath);
const content = await file.text();

Expand All @@ -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') {
Expand Down Expand Up @@ -496,6 +457,56 @@ export async function loadConfig(
}
}

return config;
}

export async function loadConfig(
explicitPath?: string,
): Promise<McpServersConfig> {
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);

Expand Down
156 changes: 129 additions & 27 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(
Expand All @@ -122,7 +126,7 @@ describe('config', () => {
env: { TOKEN: '${ANOTHER_NONEXISTENT_VAR}' },
},
},
})
}),
);

await expect(loadConfig(configPath)).rejects.toThrow('MISSING_ENV_VAR');
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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;
}
}
});
});

Expand All @@ -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 () => {
Expand All @@ -198,7 +300,7 @@ describe('config', () => {
configPath,
JSON.stringify({
mcpServers: { known: { command: 'cmd' } },
})
}),
);

const config = await loadConfig(configPath);
Expand All @@ -217,7 +319,7 @@ describe('config', () => {
beta: { command: 'b' },
gamma: { url: 'https://example.com' },
},
})
}),
);

const config = await loadConfig(configPath);
Expand Down