Skip to content
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
20 changes: 16 additions & 4 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 @@ -341,8 +341,8 @@ Restrict which tools are available from a server using `allowedTools` and `disab

The CLI searches for configuration in this order:

1. `MCP_CONFIG_PATH` environment variable
2. `-c/--config` command line argument
1. `-c/--config` command line argument
2. `MCP_CONFIG_PATH` environment variable
3. `./mcp_servers.json` (current directory)
4. `~/.mcp_servers.json`
5. `~/.config/mcp/mcp_servers.json`
Expand All @@ -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
46 changes: 35 additions & 11 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 Expand Up @@ -332,12 +346,22 @@ function createStdioTransport(config: StdioServerConfig): StdioClientTransport {
*/
export async function listTools(client: Client): Promise<ToolInfo[]> {
return withRetry(async () => {
const result = await client.listTools();
return result.tools.map((tool: Tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema as Record<string, unknown>,
}));
const allTools: ToolInfo[] = [];
let cursor: string | undefined;

do {
const result = await client.listTools(cursor ? { cursor } : undefined);
allTools.push(
...result.tools.map((tool: Tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema as Record<string, unknown>,
})),
);
cursor = result.nextCursor;
} while (cursor);

return allTools;
}, 'list tools');
}

Expand Down
17 changes: 10 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,16 @@ 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);
}

let serverConfig: ServerConfig;
try {
serverConfig = getServerConfig(config, serverName);
Expand Down
16 changes: 12 additions & 4 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 Expand Up @@ -91,7 +94,12 @@ export async function infoCommand(options: InfoOptions): Promise<void> {
} else {
// Show server details
const tools = await connection.listTools();
const instructions = await connection.getInstructions();
let instructions: string | undefined;
try {
instructions = await connection.getInstructions();
} catch {
instructions = undefined;
}

// Human-readable output
console.log(
Expand Down
3 changes: 1 addition & 2 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ async function fetchServerTools(
connection = await getConnection(serverName, serverConfig);

const tools = await connection.listTools();
const instructions = await connection.getInstructions();
debug(`${serverName}: loaded ${tools.length} tools`);
return { name: serverName, tools, instructions };
return { name: serverName, tools };
} catch (error) {
const errorMsg = (error as Error).message;
debug(`${serverName}: connection failed - ${errorMsg}`);
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
9 changes: 5 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,11 @@ Environment Variables:

Config File:
The CLI looks for mcp_servers.json in:
1. Path specified by MCP_CONFIG_PATH or -c/--config
2. ./mcp_servers.json (current directory)
3. ~/.mcp_servers.json
4. ~/.config/mcp/mcp_servers.json
1. Path specified by -c/--config
2. Path specified by MCP_CONFIG_PATH
3. ./mcp_servers.json (current directory)
4. ~/.mcp_servers.json
5. ~/.config/mcp/mcp_servers.json
`);
}

Expand Down
32 changes: 28 additions & 4 deletions src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,32 @@
* Output formatting utilities
*/

import { basename } from 'node:path';
import type { ToolInfo } from './client.js';
import type { ServerConfig } from './config.js';
import { isHttpServer } from './config.js';

function redactSensitiveUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);

if (url.username || url.password) {
url.username = url.username ? '***' : '';
url.password = url.password ? '***' : '';
}

for (const key of url.searchParams.keys()) {
if (/(token|key|secret|pass|auth)/i.test(key)) {
url.searchParams.set(key, '***');
}
}

return url.toString();
} catch {
return rawUrl;
}
}

// ANSI color codes
const colors = {
reset: '\x1b[0m',
Expand Down Expand Up @@ -115,12 +137,14 @@ export function formatServerDetails(

if (isHttpServer(config)) {
lines.push(`${color('Transport:', colors.bold)} HTTP`);
lines.push(`${color('URL:', colors.bold)} ${config.url}`);
lines.push(`${color('URL:', colors.bold)} ${redactSensitiveUrl(config.url)}`);
} else {
const argCount = config.args?.length || 0;
const commandLabel = basename(config.command);
const argSuffix = argCount > 0 ? ` (${argCount} arg${argCount === 1 ? '' : 's'} hidden)` : '';

lines.push(`${color('Transport:', colors.bold)} stdio`);
lines.push(
`${color('Command:', colors.bold)} ${config.command} ${(config.args || []).join(' ')}`,
);
lines.push(`${color('Command:', colors.bold)} ${commandLabel}${argSuffix}`);
}

if (instructions) {
Expand Down
Loading