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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,16 @@ The CLI uses `mcp_servers.json`, compatible with Claude Desktop, Gemini or VS Co
}
```

**Environment Variable Substitution:** Use `${VAR_NAME}` syntax anywhere in the config. Values are substituted at load time. By default, missing environment variables cause an error with a clear message. Set `MCP_STRICT_ENV=false` to use empty values instead (with a warning).
**Variable Substitution:** Two syntaxes are supported anywhere in the config, substituted at load time:

| Syntax | Source | Example |
|--------|--------|---------|
| `${VAR_NAME}` | Environment variable | `"Bearer ${API_TOKEN}"` |
| `{file:/path/to/file}` | File contents (trimmed) | `"Bearer {file:~/.ssh/api_token}"` |

`{file:...}` reads the file at the given path and trims whitespace (e.g., trailing newlines). The `~` prefix expands to your home directory.

By default, missing environment variables or unreadable files cause an error with a clear message. Set `MCP_STRICT_ENV=false` to use empty values instead (with a warning).

### Tool Filtering

Expand Down
47 changes: 43 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* MCP-CLI Configuration Types and Loader
*/

import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
Expand Down Expand Up @@ -317,15 +317,21 @@ function isStrictEnvMode(): boolean {

/**
* Substitute environment variables in a string
* Supports ${VAR_NAME} syntax
* Supports ${VAR_NAME} syntax and {file:path} syntax
*
* By default (strict mode), throws an error when referenced env var is not set.
* ${VAR_NAME} — substituted from environment variables
* {file:path} — substituted from file contents (trimmed, ~ expands to home dir)
*
* By default (strict mode), throws an error when referenced env var is not set
* or file cannot be read.
* Set MCP_STRICT_ENV=false to warn instead of error.
*/
function substituteEnvVars(value: string): string {
const missingVars: string[] = [];
const missingFiles: string[] = [];

const result = value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
// 1. Substitute ${VAR_NAME} from environment
let result = value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
const envValue = process.env[varName];
if (envValue === undefined) {
missingVars.push(varName);
Expand All @@ -334,6 +340,20 @@ function substituteEnvVars(value: string): string {
return envValue;
});

// 2. Substitute {file:path} from file contents
result = result.replace(/\{file:([^}]+)\}/g, (match, filePath) => {
const expandedPath = filePath.startsWith('~/')
? join(homedir(), filePath.slice(2))
: filePath;

try {
return readFileSync(expandedPath, 'utf-8').trim();
} catch (e) {
missingFiles.push(filePath);
return '';
}
});

if (missingVars.length > 0) {
const varList = missingVars.map((v) => `\${${v}}`).join(', ');
const message = `Missing environment variable${missingVars.length > 1 ? 's' : ''}: ${varList}`;
Expand All @@ -353,6 +373,25 @@ function substituteEnvVars(value: string): string {
console.error(`[mcp-cli] Warning: ${message}`);
}

if (missingFiles.length > 0) {
const fileList = missingFiles.map((f) => `{file:${f}}`).join(', ');
const message = `Cannot read file${missingFiles.length > 1 ? 's' : ''}: ${fileList}`;

if (isStrictEnvMode()) {
throw new Error(
formatCliError({
code: ErrorCode.CLIENT_ERROR,
type: 'MISSING_FILE',
message: message,
details: 'Referenced in config but file could not be read',
suggestion: 'Check file path and permissions, or set MCP_STRICT_ENV=false to use empty values',
}),
);
}
// Non-strict mode: warn but continue
console.error(`[mcp-cli] Warning: ${message}`);
}

return result;
}

Expand Down
141 changes: 141 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,147 @@ describe('config', () => {
await expect(loadConfig(configPath)).rejects.toThrow('MISSING_ENV_VAR');
});

test('substitutes {file:path} from file contents', async () => {
const tokenPath = join(tempDir, 'my_token');
await writeFile(tokenPath, 'file-secret-token\n');

const configPath = join(tempDir, 'file_config.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
url: 'https://example.com',
headers: { Authorization: 'Bearer {file:' + tokenPath + '}' },
},
},
})
);

const config = await loadConfig(configPath);
const server = config.mcpServers.test as any;
expect(server.headers.Authorization).toBe('Bearer file-secret-token');
});

test('trims file content in {file:...} substitution', async () => {
const tokenPath = join(tempDir, 'token_with_newlines');
await writeFile(tokenPath, '\n\n trimmed-value \n\n');

const configPath = join(tempDir, 'trim_config.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
command: 'echo',
env: { TOKEN: '{file:' + tokenPath + '}' },
},
},
})
);

const config = await loadConfig(configPath);
const server = config.mcpServers.test as any;
expect(server.env.TOKEN).toBe('trimmed-value');
});

test('expands ~ in {file:~/...} to home directory', async () => {
const { homedir } = await import('node:os');
const { join: joinPath } = await import('node:path');
const { rm } = await import('node:fs/promises');
const relName = '.mcp_cli_test_token_' + Date.now();
const fullPath = joinPath(homedir(), relName);
await writeFile(fullPath, 'tilde-token');

try {
const configPath = join(tempDir, 'tilde_config.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
command: 'echo',
env: { TOKEN: '{file:~/' + relName + '}' },
},
},
})
);

const config = await loadConfig(configPath);
const server = config.mcpServers.test as any;
expect(server.env.TOKEN).toBe('tilde-token');
} finally {
await rm(fullPath, { force: true });
}
});

test('{file:...} and ${VAR} coexist in same value', async () => {
const tokenPath = join(tempDir, 'coexist_token');
await writeFile(tokenPath, 'from-file');
process.env.TEST_COEXIST_VAR = 'from-env';

const configPath = join(tempDir, 'coexist_config.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
command: 'echo',
env: { COMBINED: '${TEST_COEXIST_VAR}+{file:' + tokenPath + '}' },
},
},
})
);

const config = await loadConfig(configPath);
const server = config.mcpServers.test as any;
expect(server.env.COMBINED).toBe('from-env+from-file');

delete process.env.TEST_COEXIST_VAR;
});

test('throws on missing file in strict mode (default)', async () => {
delete process.env.MCP_STRICT_ENV;

const configPath = join(tempDir, 'missing_file_strict.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
command: 'echo',
env: { TOKEN: '{file:/nonexistent/path/token}' },
},
},
})
);

await expect(loadConfig(configPath)).rejects.toThrow('MISSING_FILE');
});

test('warns on missing file in non-strict mode', async () => {
process.env.MCP_STRICT_ENV = 'false';

const configPath = join(tempDir, 'missing_file_warn.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
test: {
command: 'echo',
env: { TOKEN: 'Bearer {file:/nonexistent/path/token}' },
},
},
})
);

const config = await loadConfig(configPath);
const server = config.mcpServers.test as any;
expect(server.env.TOKEN).toBe('Bearer ');

delete process.env.MCP_STRICT_ENV;
});

test('throws error on empty server config', async () => {
const configPath = join(tempDir, 'empty_server.json');
await writeFile(
Expand Down