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
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ jobs:

- name: Build Linux x64
run: bun build --compile --minify --target=bun-linux-x64 src/index.ts --outfile dist/mcp-cli-linux-x64

- name: Build Linux ARM64
run: bun build --compile --minify --target=bun-linux-arm64 src/index.ts --outfile dist/mcp-cli-linux-arm64

- name: Build macOS x64
run: bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/mcp-cli-darwin-x64
Expand Down Expand Up @@ -95,6 +98,7 @@ jobs:
generate_release_notes: true
files: |
dist/mcp-cli-linux-x64
dist/mcp-cli-linux-arm64
dist/mcp-cli-darwin-x64
dist/mcp-cli-darwin-arm64
dist/checksums.txt
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ mcp-cli [options] call <server> <tool> <json> Call tool with JSON arguments
| Stream | Content |
|--------|---------|
| **stdout** | Tool results and human-readable info |
| **stderr** | Errors and diagnostics |
| **stderr** | Errors and diagnostics (plus live server logs only when `MCP_DEBUG=1`) |

### Commands

Expand Down Expand Up @@ -352,7 +352,7 @@ The CLI searches for configuration in this order:
| Variable | Description | Default |
|----------|-------------|---------|
| `MCP_CONFIG_PATH` | Path to config file | (none) |
| `MCP_DEBUG` | Enable debug output | `false` |
| `MCP_DEBUG` | Enable debug output and live stdio server stderr streaming | `false` |
| `MCP_TIMEOUT` | Request timeout (seconds) | `1800` (30 min) |
| `MCP_CONCURRENCY` | Servers processed in parallel (not a limit on total) | `5` |
| `MCP_MAX_RETRIES` | Retry attempts for transient errors (0 = disable) | `3` |
Expand All @@ -374,6 +374,18 @@ Traditional MCP integration loads full tool schemas into the AI's context window
- **Shell composable**: Chain with `jq`, pipes, and scripts
- **Scriptable**: AI can write shell scripts for complex workflows

### Debug logging behavior

By default, `mcp-cli` keeps successful stdio server stderr quiet so MCP/LSP startup banners do not pollute normal CLI output or agent pipelines.

If you want to see live server logs while debugging a connection, enable `MCP_DEBUG`:

```bash
MCP_DEBUG=1 mcp-cli info filesystem
```

Connection failures still include captured server stderr in the final error message, even when debug mode is off.

### Option 1: System Prompt Integration

Add this to your AI agent's system prompt for direct CLI access:
Expand Down
24 changes: 19 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Whether server stderr should be streamed live to the terminal.
*
* Default is quiet to avoid noisy MCP/LSP server logs polluting stdout/stderr
* for normal CLI use and AI-agent pipelines. Use MCP_DEBUG=1 to see live logs.
*/
export function shouldStreamServerStderr(): boolean {
return Boolean(process.env.MCP_DEBUG);
}

/**
* Execute a function with retry logic for transient failures
* Respects overall timeout budget from MCP_TIMEOUT
Expand Down Expand Up @@ -244,14 +254,16 @@ export async function connectToServer(
transport = createStdioTransport(config);

// Capture stderr for debugging - attach BEFORE connect
// Always stream stderr immediately so auth prompts are visible
// Only stream stderr live in debug mode to avoid noisy servers polluting output
const stderrStream = transport.stderr;
if (stderrStream) {
stderrStream.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrChunks.push(text);
// Always stream stderr immediately so users can see auth prompts
process.stderr.write(`[${serverName}] ${text}`);
// In debug mode, show server stderr with server name prefix
if (shouldStreamServerStderr()) {
process.stderr.write(`[${serverName}] ${text}`);
}
});
}
}
Expand All @@ -268,12 +280,14 @@ export async function connectToServer(
throw error;
}

// For successful connections, forward stderr to console
// For successful connections, forward stderr to console only in debug mode
if (!isHttpServer(config)) {
const stderrStream = (transport as StdioClientTransport).stderr;
if (stderrStream) {
stderrStream.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
if (shouldStreamServerStderr()) {
process.stderr.write(chunk);
}
});
}
}
Expand Down
22 changes: 15 additions & 7 deletions src/commands/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,6 @@ async function parseArgs(
export async function callCommand(options: CallOptions): Promise<void> {
let config: McpServersConfig;

try {
config = await loadConfig(options.configPath);
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}

let serverName: string;
let toolName: string;

Expand All @@ -129,6 +122,21 @@ export async function callCommand(options: CallOptions): Promise<void> {
process.exit(ErrorCode.CLIENT_ERROR);
}

try {
config = await loadConfig({
explicitPath: options.configPath,
serverNames: [serverName],
});
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}

try {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}

let serverConfig: ServerConfig;
try {
serverConfig = getServerConfig(config, serverName);
Expand Down
9 changes: 6 additions & 3 deletions src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,18 @@ function parseTarget(target: string): { server: string; tool?: string } {
export async function infoCommand(options: InfoOptions): Promise<void> {
let config: McpServersConfig;

const { server: serverName, tool: toolName } = parseTarget(options.target);

try {
config = await loadConfig(options.configPath);
config = await loadConfig({
explicitPath: options.configPath,
serverNames: [serverName],
});
} catch (error) {
console.error((error as Error).message);
process.exit(ErrorCode.CLIENT_ERROR);
}

const { server: serverName, tool: toolName } = parseTarget(options.target);

let serverConfig: ServerConfig;
try {
serverConfig = getServerConfig(config, serverName);
Expand Down
36 changes: 31 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export interface McpServersConfig {
mcpServers: Record<string, ServerConfig>;
}

export interface LoadConfigOptions {
explicitPath?: string;
serverNames?: string[];
}

// ============================================================================
// Tool Filtering
// ============================================================================
Expand Down Expand Up @@ -397,13 +402,16 @@ function getDefaultConfigPaths(): string[] {
* Load and parse MCP servers configuration
*/
export async function loadConfig(
explicitPath?: string,
input?: string | LoadConfigOptions,
): Promise<McpServersConfig> {
const options: LoadConfigOptions =
typeof input === 'string' ? { explicitPath: input } : input ?? {};

let configPath: string | undefined;

// Check explicit path from argument or environment
if (explicitPath) {
configPath = resolve(explicitPath);
if (options.explicitPath) {
configPath = resolve(options.explicitPath);
} else if (process.env.MCP_CONFIG_PATH) {
configPath = resolve(process.env.MCP_CONFIG_PATH);
}
Expand Down Expand Up @@ -496,8 +504,26 @@ export async function loadConfig(
}
}

// Substitute environment variables
config = substituteEnvVarsInObject(config);
// Substitute environment variables only for the relevant servers.
// This avoids warning about unrelated missing variables when a command
// targets a single server (for example: `mcp-cli info chrome-devtools`).
if (options.serverNames && options.serverNames.length > 0) {
const targetServers = new Set(options.serverNames);
const substitutedServers: Record<string, ServerConfig> = {};

for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
substitutedServers[serverName] = targetServers.has(serverName)
? substituteEnvVarsInObject(serverConfig)
: serverConfig;
}

config = {
...config,
mcpServers: substitutedServers,
};
} else {
config = substituteEnvVarsInObject(config);
}

return config;
}
Expand Down
28 changes: 28 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isTransientError,
getTimeoutMs,
getConcurrencyLimit,
shouldStreamServerStderr,
} from '../src/client';

describe('client', () => {
Expand Down Expand Up @@ -240,6 +241,33 @@ describe('client', () => {
});
});

describe('shouldStreamServerStderr', () => {
const originalEnv = process.env.MCP_DEBUG;

afterEach(() => {
if (originalEnv !== undefined) {
process.env.MCP_DEBUG = originalEnv;
} else {
delete process.env.MCP_DEBUG;
}
});

test('returns false by default (quiet mode)', () => {
delete process.env.MCP_DEBUG;
expect(shouldStreamServerStderr()).toBe(false);
});

test('returns true when MCP_DEBUG=1', () => {
process.env.MCP_DEBUG = '1';
expect(shouldStreamServerStderr()).toBe(true);
});

test('returns true when MCP_DEBUG is set to any value', () => {
process.env.MCP_DEBUG = 'true';
expect(shouldStreamServerStderr()).toBe(true);
});
});

// Note: Actually testing connectToServer requires a real MCP server
// Those tests are in the integration test suite
});
29 changes: 29 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ describe('config', () => {
await expect(loadConfig(configPath)).rejects.toThrow('MISSING_ENV_VAR');
});

test('only substitutes env vars for targeted servers', async () => {
delete process.env.MCP_STRICT_ENV;

const configPath = join(tempDir, 'targeted_env.json');
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
chrome: {
command: 'echo',
args: ['ok'],
},
context7: {
command: 'echo',
env: { API_KEY: '${MISSING_CONTEXT7_API_KEY}' },
},
},
})
);

const config = await loadConfig({
explicitPath: configPath,
serverNames: ['chrome'],
});

expect((config.mcpServers.chrome as any).command).toBe('echo');
expect((config.mcpServers.context7 as any).env.API_KEY).toBe('${MISSING_CONTEXT7_API_KEY}');
});

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