Skip to content
Draft
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `mcpc help tools/list` and other MCP method names now show the command's help instead of failing with "Unknown command" — they already worked as aliases everywhere else.

### Security

- A stdio server's `env` values are no longer stored in plaintext: they are kept in the OS keychain (like HTTP headers), shown as `<redacted>` in `sessions.json` and `--json` output, and passed to the bridge over IPC so they never appear in the process list. Sessions created before this change keep working; recreate them to move their already-stored values out of `sessions.json`.

## [0.6.0] - 2026-08-02

### Added
Expand Down
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ Implements [MCP security best practices](https://modelcontextprotocol.io/specifi

- Credentials stored in OS keychain (encrypted by system), with `0600` fallback file
- No credentials logged even in verbose mode — only log presence/absence (e.g., `refreshToken: present`)
- Headers sent to bridge via IPC after socket connect, never as command-line arguments (visible in `ps`)
- Headers and stdio `env` values sent to bridge via IPC after socket connect, never as command-line arguments (visible in `ps`)
- `sessions.json` and `profiles.json` file permissions: `0600` (user-only)

**Transport security:**
Expand Down Expand Up @@ -307,7 +307,7 @@ When making changes, follow these rules to maintain the security posture:
- Always use `ensureDir()` for creating directories (defaults to `0700`); use `mode: 0o600` for files containing secrets
- Use `execFile()` (array args) instead of `exec()` (shell string) when spawning processes
- Escape any user-controlled or server-controlled data before embedding in HTML responses
- Send sensitive data (headers, tokens) via IPC socket, never via CLI arguments or environment variables
- Send sensitive data (headers, stdio `env` values, tokens) via IPC socket, never via CLI arguments or environment variables
- Read all keychain values needed to start a bridge in the CLI **before** `spawn()`. After spawn the bridge arms a short IPC-credential timeout; on macOS a Keychain password dialog can block longer than that timeout, so a post-spawn keychain read races the bridge timer and causes ENOENT (#55). The CLI is the only process attached to a TTY and can show the dialog without the user wondering why a background process is asking. Bridge-side keychain access is permitted only on the OAuth token refresh paths (the `oauth-token-manager` callbacks and the id-jag provider callbacks in `src/bridge/index.ts`), where it is needed to persist rotated refresh tokens for long-running sessions
- Validate and sanitize all external input (URLs, session names, profile names) before use
- Default to HTTPS; only allow HTTP for localhost/127.0.0.1
Expand Down Expand Up @@ -468,7 +468,8 @@ Environment variable substitution supported: `${VAR_NAME}`

- Bearer tokens passed via `--header "Authorization: Bearer ${TOKEN}"` are NOT stored as profiles
- All session headers are stored in the OS keychain as one JSON blob per session (keychain account: `session:<name>:headers`)
- Bridge loads them automatically when making requests (delivered over IPC after spawn, never via argv)
- A stdio server's `env` values get the same treatment (keychain account: `session:<name>:env`) — config `env` routinely holds API tokens, directly or via `${VAR}` substitution
- Bridge loads both automatically when connecting (delivered over IPC after spawn, never via argv)

**CLI Commands:**

Expand Down Expand Up @@ -576,7 +577,8 @@ On failure, the error message includes instructions on how to login. This ensure
// Account: auth-profile:mcp.apify.com:personal:tokens
// Value: {"access_token": "...", "refresh_token": "...", "expires_at": ...}
// Other accounts: auth-profile:<host>:<profile>:client (registered OAuth client),
// session:<name>:headers (per-session headers), session:<name>:proxy-bearer-token
// session:<name>:headers (per-session headers), session:<name>:env (stdio env vars),
// session:<name>:proxy-bearer-token
```

## State and Data Storage
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1262,7 +1262,7 @@ For **stdio servers:**

- `command` (required) - Command to execute (e.g., `node`, `npx`, `python`)
- `args` (optional) - Array of command arguments
- `env` (optional) - Environment variables for the process
- `env` (optional) - Environment variables for the process (treated as secrets: stored in the OS keychain and shown as `<redacted>` in session output)

> **Note:** Stdio servers inherit only a minimal env whitelist from the shell
> (`PATH`, `HOME`, `SHELL`, …). Other vars — `NODE_EXTRA_CA_CERTS`, `HTTPS_PROXY`,
Expand Down Expand Up @@ -1341,6 +1341,7 @@ MCP enables arbitrary tool execution and data access - treat servers like you tr
| ---------------------- | ----------------------------------------------- |
| **OAuth tokens** | Stored in OS keychain (headless fallback: `credentials.json`, `0600`) |
| **HTTP headers** | Stored in OS keychain per-session |
| **stdio `env` values** | Stored in OS keychain per-session |
| **Bridge credentials** | Passed via Unix socket IPC, kept in memory only |
| **Process arguments** | No secrets visible in `ps aux` |
| **x402 private key** | Stored in OS keychain (fallback: `wallets.json`, `0600`) |
Expand Down
4 changes: 4 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ JSON output (--json):
`[{ protocolVersion?, supportedVersions?, capabilities?, serverInfo?, instructions?, _meta?, toolNames?, _mcpc: { ... } }]`
Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".
```

## `mcpc close`
Expand Down Expand Up @@ -196,6 +197,7 @@ JSON output (--json):
`{ protocolVersion?, supportedVersions?, capabilities?, serverInfo?, instructions?, _meta?, toolNames?, _mcpc: { ... } }`
Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".
```

## `mcpc login`
Expand Down Expand Up @@ -510,6 +512,7 @@ JSON output (--json):
`{ protocolVersion?, supportedVersions?, capabilities?, serverInfo?, instructions?, _meta?, toolNames?, _mcpc: { ... } }`
Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".
```

### `mcpc @<session> close`
Expand Down Expand Up @@ -544,6 +547,7 @@ JSON output (--json):
`{ protocolVersion?, supportedVersions?, capabilities?, serverInfo?, instructions?, _meta?, toolNames?, _mcpc: { ... } }`
Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".
```

### `mcpc @<session> grep`
Expand Down
22 changes: 22 additions & 0 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ class BridgeProcess {
// HTTP headers (received via IPC, stored in memory only)
private headers: Record<string, string> | null = null;

// Environment variables for a stdio server (received via IPC, stored in memory only).
// Kept off the command line so their values never show up in `ps` output.
private serverEnv: Record<string, string> | null = null;

// Bearer token the proxy server requires (received via IPC, stored in memory only).
// Read by the CLI before spawn and sent over IPC — never read from the keychain here,
// keeping the bridge's only keychain access on the OAuth-refresh path (see #55).
Expand Down Expand Up @@ -211,6 +215,7 @@ class BridgeProcess {
logger.debug(` clientSecret: ${credentials.clientSecret ? 'present' : 'absent'}`);
logger.debug(` privateKey: ${credentials.privateKeyPem ? 'present' : 'absent'}`);
logger.debug(` headers: ${credentials.headers ? Object.keys(credentials.headers).length : 0}`);
logger.debug(` env: ${credentials.env ? Object.keys(credentials.env).length : 0}`);
logger.debug(` proxyBearerToken: ${credentials.proxyBearerToken ? 'present' : 'absent'}`);
logger.debug(` idJag: ${credentials.idJag ? 'present' : 'absent'}`);

Expand Down Expand Up @@ -357,6 +362,15 @@ class BridgeProcess {
logger.debug(`Stored headers "${Object.keys(this.headers).join(', ')}" in memory`);
}

// Store stdio env variables if provided (merged into the transport config on connect)
if (credentials.env) {
this.serverEnv = {
...this.serverEnv,
...credentials.env,
};
logger.debug(`Stored env variables "${Object.keys(this.serverEnv).join(', ')}" in memory`);
}

// Store the proxy bearer token if provided (used by startProxyServer)
if (credentials.proxyBearerToken) {
this.proxyBearerToken = credentials.proxyBearerToken;
Expand Down Expand Up @@ -658,6 +672,14 @@ class BridgeProcess {
serverConfig = await this.updateTransportAuth();
}

// Restore the stdio server's env variables. They are stripped from the command-line
// config by the CLI and delivered over IPC instead, so their values (often API tokens)
// are never visible in `ps` output.
if (this.serverEnv && serverConfig.command) {
serverConfig.env = { ...serverConfig.env, ...this.serverEnv };
logger.debug(`Added ${Object.keys(this.serverEnv).length} env variables to transport`);
}

logger.debug('Building MCP client config...');
logger.debug(` this.authProvider is set: ${!!this.authProvider}`);
logger.debug(` this.x402Wallet is set: ${!!this.x402Wallet}`);
Expand Down
32 changes: 21 additions & 11 deletions src/cli/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
generateSessionName,
normalizeServerUrl,
validateProfileName,
redactHeaders,
redactValues,
redactServerConfigSecrets,
AuthError,
ClientError,
isAuthenticationError,
Expand Down Expand Up @@ -47,6 +48,7 @@
import { startBridge, StartBridgeOptions, stopBridge } from '../../lib/bridge-manager.js';
import {
storeKeychainSessionHeaders,
storeKeychainSessionEnv,
storeKeychainProxyBearerToken,
} from '../../lib/auth/keychain.js';
import { getWallet } from '../../lib/wallets.js';
Expand Down Expand Up @@ -152,7 +154,7 @@

/**
* Connect to a session via the bridge and build a populated ConnectResultEntry from its
* server details and tools list. The entry's `_mcpc.server` headers are redacted.
* server details and tools list. The entry's `_mcpc.server` headers and env are redacted.
*/
async function buildConnectResultEntry(
sessionName: string,
Expand All @@ -177,12 +179,7 @@
const tools = (await client.listAllTools()).tools;

const server: ServerConfig | undefined = context.serverConfig
? {
...context.serverConfig,
...(context.serverConfig.headers && {
headers: redactHeaders(context.serverConfig.headers),
}),
}
? redactServerConfigSecrets(context.serverConfig)
: undefined;

return {
Expand Down Expand Up @@ -374,6 +371,17 @@
await storeKeychainSessionHeaders(name, headers);
}

// Same for a stdio server's env variables: they routinely hold API tokens (directly or
// via `${VAR}` substitution in the config file), so they go to the keychain too
let env: Record<string, string> | undefined;
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
env = { ...serverConfig.env };
logger.debug(
`Storing ${Object.keys(env).length} env variables for session ${name} in keychain`
);
await storeKeychainSessionEnv(name, env);
}

// Store proxy bearer token in keychain (if provided)
if (options.proxyBearerToken) {
logger.debug(`Storing proxy bearer token for session ${name} in keychain`);
Expand All @@ -390,12 +398,13 @@
}

// Create or update session record (without pid - that comes from startBridge)
// Store serverConfig with headers redacted (actual values in keychain)
// Store serverConfig with headers and env redacted (actual values in keychain)
const isReconnect = !!existingSession;
const { headers: _originalHeaders, ...baseTransportConfig } = serverConfig;
const { headers: _originalHeaders, env: _originalEnv, ...baseTransportConfig } = serverConfig;
const sessionTransportConfig: ServerConfig = {
...baseTransportConfig,
...(headers && { headers: redactHeaders(headers) }),
...(headers && { headers: redactValues(headers) }),
...(env && { env: redactValues(env) }),
};

const sessionUpdate: Parameters<typeof updateSession>[1] = {
Expand Down Expand Up @@ -429,6 +438,7 @@
serverConfig,
verbose: options.verbose || false,
...(headers && { headers }),
...(env && { env }),
...(profileName && { profileName }),
...(proxyConfig && { proxyConfig }),
...(options.x402 && { x402: options.x402 }),
Expand Down Expand Up @@ -772,7 +782,7 @@
);

let results: BulkConnectResult[] = settled.map((outcome, i) => {
const base = entries[i]!;

Check warning on line 785 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 22

Forbidden non-null assertion

Check warning on line 785 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 26

Forbidden non-null assertion

Check warning on line 785 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 24

Forbidden non-null assertion
if (outcome.status === 'fulfilled') {
return { ...base, status: liveSet.has(base.sessionName) ? 'active' : 'created' };
}
Expand Down
19 changes: 9 additions & 10 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
OutputMode,
isProcessAlive,
getServerHost,
redactHeaders,
redactServerConfigSecrets,
ClientError,
} from '../../lib/index.js';
import { DISCONNECTED_THRESHOLD_MILLIS } from '../../lib/types.js';
Expand All @@ -37,6 +37,7 @@ import {
StartBridgeOptions,
stopBridge,
reconnectCrashedSessions,
resolveSessionEnv,
} from '../../lib/bridge-manager.js';
import chalk from 'chalk';
import { createLogger } from '../../lib/logger.js';
Expand Down Expand Up @@ -326,13 +327,8 @@ export async function showServerDetails(
// 2026-07-28 ones. `ServerDetails` reconciles the two — see its doc comment.
// https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
// https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
// Build _mcpc.server with redacted headers for security
const server: ServerConfig = {
...context.serverConfig,
...(context.serverConfig?.headers && {
headers: redactHeaders(context.serverConfig.headers),
}),
};
// Build _mcpc.server with redacted headers and env values for security
const server: ServerConfig = redactServerConfigSecrets({ ...context.serverConfig });

// The bridge log path is useful debug context for callers — only meaningful for
// session targets (those starting with "@"); ad-hoc URL/config targets have no
Expand Down Expand Up @@ -406,9 +402,11 @@ export async function restartSession(
throw new ClientError(`Session ${name} has no server configuration`);
}

// Load headers from keychain if present
// Load headers and stdio env variables from keychain if present. The copies in
// sessions.json only carry key names — their values are redacted.
const { readKeychainSessionHeaders } = await import('../../lib/auth/keychain.js');
const headers = await readKeychainSessionHeaders(name);
const env = await resolveSessionEnv(name, serverConfig.env);

// Resolve auth profile: use stored profile, or auto-detect a "default" profile.
// This handles the case where user creates a session without auth, then later runs
Expand All @@ -432,9 +430,10 @@ export async function restartSession(
// the session ID, the session is marked as expired.
const bridgeOptions: StartBridgeOptions = {
sessionName: name,
serverConfig: { ...serverConfig, ...(headers && { headers }) },
serverConfig: { ...serverConfig, ...(headers && { headers }), ...(env && { env }) },
verbose: options.verbose || false,
...(headers && { headers }),
...(env && { env }),
...(profileName && { profileName }),
...(session.proxy && { proxyConfig: session.proxy }),
...(session.x402 && { x402: session.x402 }),
Expand Down
13 changes: 12 additions & 1 deletion src/cli/help-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ const SERVER_DETAILS_SCHEMA_URLS = [
`${SCHEMA_BASE}#discoverresult`,
];

/**
* Stated with the shape so an agent reading the output never mistakes the redaction
* sentinel for a real credential: the values live in the OS keychain, not here.
*/
const SERVER_DETAILS_SECRETS_NOTE =
'Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".';

/**
* Standard "JSON output (--json):" block for every command that prints server details:
* `connect` (an array of entries), `restart` (the restarted session), and the `mcpc
Expand All @@ -57,7 +64,11 @@ export function serverDetailsJsonHelp(returns: 'object' | 'array'): string {
: '`InitializeResult` or `DiscoverResult` object';
const shape =
returns === 'array' ? `\`[${SERVER_DETAILS_JSON_SHAPE}]\`` : `\`${SERVER_DETAILS_JSON_SHAPE}\``;
return jsonHelp(`${subject} ${SERVER_DETAILS_JSON_META}`, shape, SERVER_DETAILS_SCHEMA_URLS);
return `${jsonHelp(
`${subject} ${SERVER_DETAILS_JSON_META}`,
shape,
SERVER_DETAILS_SCHEMA_URLS
)} ${SERVER_DETAILS_SECRETS_NOTE}\n`;
}

/**
Expand Down
31 changes: 31 additions & 0 deletions src/lib/auth/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ const oauthIdJagAccount = (serverUrl: string, profileName: string): string =>

const sessionHeadersAccount = (sessionName: string): string => `session:${sessionName}:headers`;

const sessionEnvAccount = (sessionName: string): string => `session:${sessionName}:env`;

const proxyBearerTokenAccount = (sessionName: string): string =>
`session:${sessionName}:proxy-bearer-token`;

Expand Down Expand Up @@ -418,6 +420,35 @@ export async function removeKeychainSessionHeaders(sessionName: string): Promise
return keychainDelete(sessionHeadersAccount(sessionName));
}

/**
* Store stdio environment variables for a session. Treated as secrets: config `env`
* values commonly hold API tokens (directly or via `${VAR}` substitution).
*/
export async function storeKeychainSessionEnv(
sessionName: string,
env: Record<string, string>
): Promise<void> {
logger.debug(`Storing env variables for session ${sessionName}`);
await keychainSet(sessionEnvAccount(sessionName), JSON.stringify(env));
}

/** Read stdio environment variables for a session. */
export async function readKeychainSessionEnv(
sessionName: string
): Promise<Record<string, string> | undefined> {
logger.debug(`Retrieving env variables for session ${sessionName}`);
return keychainGetParsed<Record<string, string>>(
sessionEnvAccount(sessionName),
'session env variables'
);
}

/** Delete stdio environment variables for a session. */
export async function removeKeychainSessionEnv(sessionName: string): Promise<boolean> {
logger.debug(`Deleting env variables for session ${sessionName}`);
return keychainDelete(sessionEnvAccount(sessionName));
}

/** Store the bearer token used to authenticate requests to the proxy server. */
export async function storeKeychainProxyBearerToken(
sessionName: string,
Expand Down
Loading
Loading