A custom MCP TypeScript client can successfully initialize a stdio server and list its tools while failing to pass an environment variable that the parent process has. In @modelcontextprotocol/sdk 1.27.1, StdioClientTransport starts the child with a selected default environment plus the values explicitly supplied through env. It does not automatically forward every variable in process.env.
This guide addresses that specific situation: you maintain the Node.js MCP client, the server starts, and a server-side setting is unexpectedly missing. It does not establish how Claude Code, Claude Desktop, or every other MCP host prepares its subprocess environment. Those applications can add their own behavior around the SDK.
What we verified, and what we did not
We tested an already-installed SDK 1.27.1 on macOS with Node.js 25.8.0 on October 7, 2026. A public dummy value replaced any real credential. Our server returned only whether that dummy value arrived.
| Client setup | Dummy variable present in child? | MCP initialization | tools/list and tools/call |
|---|---|---|---|
SDK transport with env omitted | No | Passed | Passed |
SDK transport with env: {} | No | Passed | Passed |
| SDK transport with the dummy explicitly listed | Yes | Passed | Passed |
A separate ordinary Node spawnSync control, with no env option, did inherit the dummy value. The successful MCP exchanges matter: this was a missing application setting, not a failed protocol handshake.
These are version-specific observations, not a claim about every SDK release, Windows, or an application that embeds the SDK. The reproduction below uses ordinary package imports in place of the absolute paths used to access our existing installation. Those portable snippets received syntax checks; we did not perform a fresh dependency installation for this guide.
Why a successful connection is not enough
For the SDK version we inspected, subprocess creation combines getDefaultEnvironment() with the supplied env map. On our macOS test, the default list includes basic process variables such as HOME and PATH, but not our arbitrary test variable. Explicit values can override the defaults. See the official SDK 1.27.1 stdio implementation, pinned to the version tested here.
By comparison, Node’s subprocess API defaults its environment to process.env when no explicit environment is supplied. See the Node child-process reference.
The practical consequence is easy to miss. You can demonstrate that a server works when launched directly from your terminal, then launch the same executable through a custom MCP client and observe a different environment. A successful initialize response only shows that the client and server established a protocol session. It does not prove that the server received every setting its tools will need later.
Also, env: {} is not an empty-environment sandbox in the tested SDK. The SDK’s defaults still participate. Do not use that option as evidence that a subprocess has no access to inherited information.
A small, version-pinned reproduction
Use an isolated test directory with @modelcontextprotocol/sdk installed at exactly 1.27.1, and record your Node version. The fixture is JavaScript ESM using the TypeScript SDK’s public exports; no TypeScript compilation is needed. Preserve your dependency lockfile. If preparing a new disposable test directory, the dependency command is:
npm install --save-exact @modelcontextprotocol/sdk@1.27.1
node --version
npm ls @modelcontextprotocol/sdk
This is a reader setup step, not a command we ran during this verification. Do not run it inside an unrelated production project just to follow the example.
Save this as env-server.mjs:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = new McpServer({ name: 'env-fixture', version: '1.0.0' });
server.registerTool(
'env_probe',
{ description: 'Check a public test sentinel only' },
async () => ({
content: [{
type: 'text',
text: JSON.stringify({
present: process.env.PS_ARTICLE_PROBE_ONLY === 'public-test-sentinel'
})
}]
})
);
await server.connect(new StdioServerTransport());
Save the client beside it as env-client.mjs:
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
process.env.PS_ARTICLE_PROBE_ONLY = 'public-test-sentinel';
const cases = [
['omitted', {}, false],
['empty', { env: {} }, false],
['explicit', {
env: { PS_ARTICLE_PROBE_ONLY: 'public-test-sentinel' }
}, true]
];
for (const [name, options, expected] of cases) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL('./env-server.mjs', import.meta.url))],
...options
});
const client = new Client({ name: 'fixture-client', version: '1.0.0' });
try {
await client.connect(transport);
const list = await client.listTools();
assert.equal(list.tools[0].name, 'env_probe');
const result = await client.callTool({ name: 'env_probe', arguments: {} });
assert.equal(JSON.parse(result.content[0].text).present, expected);
console.log(`${name}: PASS; sentinel present=${expected}`);
} finally {
await client.close();
}
}
Run node env-client.mjs. The expected results for the tested version are:
omitted: PASS; sentinel present=false
empty: PASS; sentinel present=false
explicit: PASS; sentinel present=true
The server keeps diagnostic output off stdout; its stdout belongs to MCP. The client’s console output is separate and is not the server’s protocol stream. The fixture deliberately uses process.execPath and a path relative to the client’s own module, so finding Node or resolving the server file does not become a second variable in this test.
Fix the missing setting with an explicit allowlist
First identify the setting your server actually requires. Then pass that setting explicitly at the process boundary. Here is a client-side construction pattern for an application-defined variable named MY_SERVICE_API_TOKEN:
import { fileURLToPath } from 'node:url';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const token = process.env.MY_SERVICE_API_TOKEN;
if (!token) {
throw new Error('MY_SERVICE_API_TOKEN must be set before starting the client');
}
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL('./service-server.mjs', import.meta.url))],
env: {
MY_SERVICE_API_TOKEN: token,
LOG_LEVEL: 'warn'
}
});
Replace the variable and server path with the names your application uses, then connect this transport through your existing MCP client. This example validates presence; it does not validate the token or test access to any service. No credential is embedded in the source.
Avoid reaching immediately for env: { ...process.env }. That forwards unrelated values to the child as well as the one it needs. A focused allowlist makes the dependency visible to a reviewer. It is an allowlist of your additional application settings, not a replacement for the SDK’s default environment.
Keep the diagnosis narrower than the symptom
Before changing credentials or reinstalling packages, locate the failing boundary:
- Child cannot start: inspect the executable, file path, permissions and launch error. Environment inheritance has not yet been demonstrated as the cause.
- MCP initialization fails: inspect the transport and protocol output. Do not infer a missing API key from a handshake error alone.
- Handshake succeeds but an application setting is absent: use a dummy presence probe and inspect the client’s environment construction.
- Setting arrives but an API call fails: investigate the remote service response and the credential’s scope separately. This fixture does not test either.
Do not print process.env, real token values, or full connection strings while debugging. Even a presence probe should stay inside an authorized local test. Passing a value explicitly does not make an untrusted subprocess safe to receive it.
If you are configuring a host application’s .mcp.json rather than writing the client, use that application’s documented configuration behavior. Do not copy a JavaScript StdioClientTransport object into a JSON configuration file or assume this SDK test proves what the application does.
For broader transport tests, see testing and debugging MCP servers. For the separate host-configuration surface, see the MCP JSON configuration reference. This article adds a three-case subprocess reproduction to those guides; it does not replace their complete workflows. Browse the rules gallery for examples you can adapt into project instructions once the behavior is verified in your own client.