diff --git a/README.md b/README.md index 4d1c6a3..cd25f18 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/config.ts b/src/config.ts index 99a4e25..2b6dd3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { @@ -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); @@ -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}`; @@ -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; } diff --git a/tests/config.test.ts b/tests/config.test.ts index a48602c..62fb350 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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(