Skip to content

Commit 2aaa415

Browse files
committed
Treat stdio env values as secrets, like HTTP headers
A config entry's `env` routinely holds API tokens — directly or via `${VAR}` substitution — but only `headers` were protected. Resolved `env` values were written to `sessions.json` in plaintext, printed verbatim by `mcpc --json @session` / `connect --json`, and passed to the bridge in its command line, where `ps` exposed them. They now follow the exact same path as headers: stored in the OS keychain (`session:<name>:env`), redacted to `<redacted>` in `sessions.json` and all `--json` output, and delivered to the bridge over IPC after spawn. The bridge merges them back into the stdio transport config, so servers still get their environment unchanged. Sessions written before this change keep working — their on-disk plaintext is used as is; recreate a session to move its values into the keychain. Rebuilt on main after the draft branch was squashed in as #316. Fixes #341 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rmdd458q2Bx68bPxuUtQGa
1 parent 096e770 commit 2aaa415

17 files changed

Lines changed: 545 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- `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.
1818

19+
### Security
20+
21+
- 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`.
22+
1923
## [0.6.0] - 2026-08-02
2024

2125
### Added

‎CLAUDE.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ Implements [MCP security best practices](https://modelcontextprotocol.io/specifi
276276

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

282282
**Transport security:**
@@ -307,7 +307,7 @@ When making changes, follow these rules to maintain the security posture:
307307
- Always use `ensureDir()` for creating directories (defaults to `0700`); use `mode: 0o600` for files containing secrets
308308
- Use `execFile()` (array args) instead of `exec()` (shell string) when spawning processes
309309
- Escape any user-controlled or server-controlled data before embedding in HTML responses
310-
- Send sensitive data (headers, tokens) via IPC socket, never via CLI arguments or environment variables
310+
- Send sensitive data (headers, stdio `env` values, tokens) via IPC socket, never via CLI arguments or environment variables
311311
- 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
312312
- Validate and sanitize all external input (URLs, session names, profile names) before use
313313
- Default to HTTPS; only allow HTTP for localhost/127.0.0.1
@@ -468,7 +468,8 @@ Environment variable substitution supported: `${VAR_NAME}`
468468

469469
- Bearer tokens passed via `--header "Authorization: Bearer ${TOKEN}"` are NOT stored as profiles
470470
- All session headers are stored in the OS keychain as one JSON blob per session (keychain account: `session:<name>:headers`)
471-
- Bridge loads them automatically when making requests (delivered over IPC after spawn, never via argv)
471+
- 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
472+
- Bridge loads both automatically when connecting (delivered over IPC after spawn, never via argv)
472473

473474
**CLI Commands:**
474475

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

582584
## State and Data Storage

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1262,7 +1262,7 @@ For **stdio servers:**
12621262

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

12671267
> **Note:** Stdio servers inherit only a minimal env whitelist from the shell
12681268
> (`PATH`, `HOME`, `SHELL`, …). Other vars — `NODE_EXTRA_CA_CERTS`, `HTTPS_PROXY`,
@@ -1341,6 +1341,7 @@ MCP enables arbitrary tool execution and data access - treat servers like you tr
13411341
| ---------------------- | ----------------------------------------------- |
13421342
| **OAuth tokens** | Stored in OS keychain (headless fallback: `credentials.json`, `0600`) |
13431343
| **HTTP headers** | Stored in OS keychain per-session |
1344+
| **stdio `env` values** | Stored in OS keychain per-session |
13441345
| **Bridge credentials** | Passed via Unix socket IPC, kept in memory only |
13451346
| **Process arguments** | No secrets visible in `ps aux` |
13461347
| **x402 private key** | Stored in OS keychain (fallback: `wallets.json`, `0600`) |

‎docs/REFERENCE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ JSON output (--json):
162162
`[{ protocolVersion?, supportedVersions?, capabilities?, serverInfo?, instructions?, _meta?, toolNames?, _mcpc: { ... } }]`
163163
Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
164164
https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
165+
Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".
165166
```
166167

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

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

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

549553
### `mcpc @<session> grep`

‎src/bridge/index.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ class BridgeProcess {
131131
// HTTP headers (received via IPC, stored in memory only)
132132
private headers: Record<string, string> | null = null;
133133

134+
// Environment variables for a stdio server (received via IPC, stored in memory only).
135+
// Kept off the command line so their values never show up in `ps` output.
136+
private serverEnv: Record<string, string> | null = null;
137+
134138
// Bearer token the proxy server requires (received via IPC, stored in memory only).
135139
// Read by the CLI before spawn and sent over IPC — never read from the keychain here,
136140
// keeping the bridge's only keychain access on the OAuth-refresh path (see #55).
@@ -211,6 +215,7 @@ class BridgeProcess {
211215
logger.debug(` clientSecret: ${credentials.clientSecret ? 'present' : 'absent'}`);
212216
logger.debug(` privateKey: ${credentials.privateKeyPem ? 'present' : 'absent'}`);
213217
logger.debug(` headers: ${credentials.headers ? Object.keys(credentials.headers).length : 0}`);
218+
logger.debug(` env: ${credentials.env ? Object.keys(credentials.env).length : 0}`);
214219
logger.debug(` proxyBearerToken: ${credentials.proxyBearerToken ? 'present' : 'absent'}`);
215220
logger.debug(` idJag: ${credentials.idJag ? 'present' : 'absent'}`);
216221

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

365+
// Store stdio env variables if provided (merged into the transport config on connect)
366+
if (credentials.env) {
367+
this.serverEnv = {
368+
...this.serverEnv,
369+
...credentials.env,
370+
};
371+
logger.debug(`Stored env variables "${Object.keys(this.serverEnv).join(', ')}" in memory`);
372+
}
373+
360374
// Store the proxy bearer token if provided (used by startProxyServer)
361375
if (credentials.proxyBearerToken) {
362376
this.proxyBearerToken = credentials.proxyBearerToken;
@@ -658,6 +672,14 @@ class BridgeProcess {
658672
serverConfig = await this.updateTransportAuth();
659673
}
660674

675+
// Restore the stdio server's env variables. They are stripped from the command-line
676+
// config by the CLI and delivered over IPC instead, so their values (often API tokens)
677+
// are never visible in `ps` output.
678+
if (this.serverEnv && serverConfig.command) {
679+
serverConfig.env = { ...serverConfig.env, ...this.serverEnv };
680+
logger.debug(`Added ${Object.keys(this.serverEnv).length} env variables to transport`);
681+
}
682+
661683
logger.debug('Building MCP client config...');
662684
logger.debug(` this.authProvider is set: ${!!this.authProvider}`);
663685
logger.debug(` this.x402Wallet is set: ${!!this.x402Wallet}`);

‎src/cli/commands/connect.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
generateSessionName,
1515
normalizeServerUrl,
1616
validateProfileName,
17-
redactHeaders,
17+
redactValues,
18+
redactServerConfigSecrets,
1819
AuthError,
1920
ClientError,
2021
isAuthenticationError,
@@ -47,6 +48,7 @@ import {
4748
import { startBridge, StartBridgeOptions, stopBridge } from '../../lib/bridge-manager.js';
4849
import {
4950
storeKeychainSessionHeaders,
51+
storeKeychainSessionEnv,
5052
storeKeychainProxyBearerToken,
5153
} from '../../lib/auth/keychain.js';
5254
import { getWallet } from '../../lib/wallets.js';
@@ -152,7 +154,7 @@ type ConnectSessionOptions = {
152154

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

179181
const server: ServerConfig | undefined = context.serverConfig
180-
? {
181-
...context.serverConfig,
182-
...(context.serverConfig.headers && {
183-
headers: redactHeaders(context.serverConfig.headers),
184-
}),
185-
}
182+
? redactServerConfigSecrets(context.serverConfig)
186183
: undefined;
187184

188185
return {
@@ -374,6 +371,17 @@ export async function connectSession(
374371
await storeKeychainSessionHeaders(name, headers);
375372
}
376373

374+
// Same for a stdio server's env variables: they routinely hold API tokens (directly or
375+
// via `${VAR}` substitution in the config file), so they go to the keychain too
376+
let env: Record<string, string> | undefined;
377+
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
378+
env = { ...serverConfig.env };
379+
logger.debug(
380+
`Storing ${Object.keys(env).length} env variables for session ${name} in keychain`
381+
);
382+
await storeKeychainSessionEnv(name, env);
383+
}
384+
377385
// Store proxy bearer token in keychain (if provided)
378386
if (options.proxyBearerToken) {
379387
logger.debug(`Storing proxy bearer token for session ${name} in keychain`);
@@ -390,12 +398,13 @@ export async function connectSession(
390398
}
391399

392400
// Create or update session record (without pid - that comes from startBridge)
393-
// Store serverConfig with headers redacted (actual values in keychain)
401+
// Store serverConfig with headers and env redacted (actual values in keychain)
394402
const isReconnect = !!existingSession;
395-
const { headers: _originalHeaders, ...baseTransportConfig } = serverConfig;
403+
const { headers: _originalHeaders, env: _originalEnv, ...baseTransportConfig } = serverConfig;
396404
const sessionTransportConfig: ServerConfig = {
397405
...baseTransportConfig,
398-
...(headers && { headers: redactHeaders(headers) }),
406+
...(headers && { headers: redactValues(headers) }),
407+
...(env && { env: redactValues(env) }),
399408
};
400409

401410
const sessionUpdate: Parameters<typeof updateSession>[1] = {
@@ -429,6 +438,7 @@ export async function connectSession(
429438
serverConfig,
430439
verbose: options.verbose || false,
431440
...(headers && { headers }),
441+
...(env && { env }),
432442
...(profileName && { profileName }),
433443
...(proxyConfig && { proxyConfig }),
434444
...(options.x402 && { x402: options.x402 }),

‎src/cli/commands/sessions.ts‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
OutputMode,
1111
isProcessAlive,
1212
getServerHost,
13-
redactHeaders,
13+
redactServerConfigSecrets,
1414
ClientError,
1515
} from '../../lib/index.js';
1616
import { DISCONNECTED_THRESHOLD_MILLIS } from '../../lib/types.js';
@@ -37,6 +37,7 @@ import {
3737
StartBridgeOptions,
3838
stopBridge,
3939
reconnectCrashedSessions,
40+
resolveSessionEnv,
4041
} from '../../lib/bridge-manager.js';
4142
import chalk from 'chalk';
4243
import { createLogger } from '../../lib/logger.js';
@@ -326,13 +327,8 @@ export async function showServerDetails(
326327
// 2026-07-28 ones. `ServerDetails` reconciles the two — see its doc comment.
327328
// https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult
328329
// https://modelcontextprotocol.io/specification/2026-07-28/schema#discoverresult
329-
// Build _mcpc.server with redacted headers for security
330-
const server: ServerConfig = {
331-
...context.serverConfig,
332-
...(context.serverConfig?.headers && {
333-
headers: redactHeaders(context.serverConfig.headers),
334-
}),
335-
};
330+
// Build _mcpc.server with redacted headers and env values for security
331+
const server: ServerConfig = redactServerConfigSecrets({ ...context.serverConfig });
336332

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

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

413411
// Resolve auth profile: use stored profile, or auto-detect a "default" profile.
414412
// This handles the case where user creates a session without auth, then later runs
@@ -432,9 +430,10 @@ export async function restartSession(
432430
// the session ID, the session is marked as expired.
433431
const bridgeOptions: StartBridgeOptions = {
434432
sessionName: name,
435-
serverConfig: { ...serverConfig, ...(headers && { headers }) },
433+
serverConfig: { ...serverConfig, ...(headers && { headers }), ...(env && { env }) },
436434
verbose: options.verbose || false,
437435
...(headers && { headers }),
436+
...(env && { env }),
438437
...(profileName && { profileName }),
439438
...(session.proxy && { proxyConfig: session.proxy }),
440439
...(session.x402 && { x402: session.x402 }),

‎src/cli/help-text.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ const SERVER_DETAILS_SCHEMA_URLS = [
4242
`${SCHEMA_BASE}#discoverresult`,
4343
];
4444

45+
/**
46+
* Stated with the shape so an agent reading the output never mistakes the redaction
47+
* sentinel for a real credential: the values live in the OS keychain, not here.
48+
*/
49+
const SERVER_DETAILS_SECRETS_NOTE =
50+
'Secrets in `server` (`headers`, `env`) are always shown as "<redacted>".';
51+
4552
/**
4653
* Standard "JSON output (--json):" block for every command that prints server details:
4754
* `connect` (an array of entries), `restart` (the restarted session), and the `mcpc
@@ -57,7 +64,11 @@ export function serverDetailsJsonHelp(returns: 'object' | 'array'): string {
5764
: '`InitializeResult` or `DiscoverResult` object';
5865
const shape =
5966
returns === 'array' ? `\`[${SERVER_DETAILS_JSON_SHAPE}]\`` : `\`${SERVER_DETAILS_JSON_SHAPE}\``;
60-
return jsonHelp(`${subject} ${SERVER_DETAILS_JSON_META}`, shape, SERVER_DETAILS_SCHEMA_URLS);
67+
return `${jsonHelp(
68+
`${subject} ${SERVER_DETAILS_JSON_META}`,
69+
shape,
70+
SERVER_DETAILS_SCHEMA_URLS
71+
)} ${SERVER_DETAILS_SECRETS_NOTE}\n`;
6172
}
6273

6374
/**

‎src/lib/auth/keychain.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ const oauthIdJagAccount = (serverUrl: string, profileName: string): string =>
233233

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

236+
const sessionEnvAccount = (sessionName: string): string => `session:${sessionName}:env`;
237+
236238
const proxyBearerTokenAccount = (sessionName: string): string =>
237239
`session:${sessionName}:proxy-bearer-token`;
238240

@@ -418,6 +420,35 @@ export async function removeKeychainSessionHeaders(sessionName: string): Promise
418420
return keychainDelete(sessionHeadersAccount(sessionName));
419421
}
420422

423+
/**
424+
* Store stdio environment variables for a session. Treated as secrets: config `env`
425+
* values commonly hold API tokens (directly or via `${VAR}` substitution).
426+
*/
427+
export async function storeKeychainSessionEnv(
428+
sessionName: string,
429+
env: Record<string, string>
430+
): Promise<void> {
431+
logger.debug(`Storing env variables for session ${sessionName}`);
432+
await keychainSet(sessionEnvAccount(sessionName), JSON.stringify(env));
433+
}
434+
435+
/** Read stdio environment variables for a session. */
436+
export async function readKeychainSessionEnv(
437+
sessionName: string
438+
): Promise<Record<string, string> | undefined> {
439+
logger.debug(`Retrieving env variables for session ${sessionName}`);
440+
return keychainGetParsed<Record<string, string>>(
441+
sessionEnvAccount(sessionName),
442+
'session env variables'
443+
);
444+
}
445+
446+
/** Delete stdio environment variables for a session. */
447+
export async function removeKeychainSessionEnv(sessionName: string): Promise<boolean> {
448+
logger.debug(`Deleting env variables for session ${sessionName}`);
449+
return keychainDelete(sessionEnvAccount(sessionName));
450+
}
451+
421452
/** Store the bearer token used to authenticate requests to the proxy server. */
422453
export async function storeKeychainProxyBearerToken(
423454
sessionName: string,

0 commit comments

Comments
 (0)