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
5 changes: 5 additions & 0 deletions .changeset/fix-stdio-windows-env-casing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/sdk': patch
---

On Windows, an explicit `env` entry passed to `StdioClientTransport` now replaces the inherited default with the same name in any casing, so `Path` no longer loses to the inherited `PATH`.
31 changes: 26 additions & 5 deletions src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type StdioServerParameters = {
/**
* The environment to use when spawning the process.
*
* If not specified, the result of getDefaultEnvironment() will be used.
* Merged over the result of getDefaultEnvironment(), with these values taking precedence. On Windows,
* names match case-insensitively, so `Path` here replaces the inherited `PATH`.
*/
env?: Record<string, string>;

Expand Down Expand Up @@ -92,6 +93,29 @@ export function getDefaultEnvironment(): Record<string, string> {
return env;
}

/**
* Merges the inherited default environment with the explicitly given one, letting explicit values win.
*
* Environment variable names are case-insensitive on Windows, so an explicit `Path` must replace the
* inherited `PATH` rather than sit next to it as a second key: given both, Node keeps only one, and
* `PATH` sorts first, so the caller's value would be silently dropped.
*/
function mergeEnvironment(defaultEnv: Record<string, string>, customEnv?: Record<string, string>): Record<string, string> {
if (process.platform !== 'win32' || !customEnv) {
return { ...defaultEnv, ...customEnv };
}

const customKeys = new Set(Object.keys(customEnv).map(key => key.toUpperCase()));
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(defaultEnv)) {
if (!customKeys.has(key.toUpperCase())) {
env[key] = value;
}
}

return { ...env, ...customEnv };
}

/**
* Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout.
*
Expand Down Expand Up @@ -128,10 +152,7 @@ export class StdioClientTransport implements Transport {
return new Promise((resolve, reject) => {
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
// merge default env with server env because mcp server needs some env vars
env: {
...getDefaultEnvironment(),
...this._serverParams.env
},
env: mergeEnvironment(getDefaultEnvironment(), this._serverParams.env),
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
windowsHide: process.platform === 'win32',
Expand Down
54 changes: 54 additions & 0 deletions test/client/cross-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,60 @@ describe('StdioClientTransport using cross-spawn', () => {
);
});

describe('environment variable name casing', () => {
const originalPlatform = process.platform;

afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform
});
vi.unstubAllEnvs();
});

const spawnedEnv = (): Record<string, string> => {
const options = mockSpawn.mock.calls[0]?.[2] as { env: Record<string, string> };
return options.env;
};

test('should let an explicit env key replace an inherited default in any casing on Windows', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32'
});

vi.stubEnv('PATH', 'C:\\inherited');

const transport = new StdioClientTransport({
command: 'test-command',
env: { Path: 'C:\\explicit-only' }
});

await transport.start();

// Every other inherited default must survive; only the PATH entry is replaced.
const { PATH: inheritedPath, ...otherDefaults } = getDefaultEnvironment();
expect(inheritedPath).toBe('C:\\inherited');
expect(spawnedEnv()).toEqual({ ...otherDefaults, Path: 'C:\\explicit-only' });
});

test('should keep env keys case-sensitive on non-Windows', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux'
});

const transport = new StdioClientTransport({
command: 'test-command',
env: { Path: '/explicit-only' }
});

await transport.start();

expect(spawnedEnv()).toEqual({
...getDefaultEnvironment(),
Path: '/explicit-only'
});
});
});

test('should send messages correctly', async () => {
const transport = new StdioClientTransport({
command: 'test-command'
Expand Down
28 changes: 28 additions & 0 deletions test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ test('should return child process pid', async () => {
expect(client.pid).toBeNull();
});

test.runIf(process.platform === 'win32')('should pass an explicit Path to the child instead of the inherited PATH on Windows', async () => {
// Use the absolute node path: the child's PATH is replaced, so a bare `node` would not resolve.
const client = new StdioClientTransport({
command: process.execPath,
args: [
'-e',
"const keys = Object.keys(process.env).filter(k => k.toUpperCase() === 'PATH'); process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'env', params: { keys, explicit: process.env.PATH === 'C:\\\\explicit-only' } }) + '\\n')"
],
env: { Path: 'C:\\explicit-only' }
});

const received = new Promise<JSONRPCMessage>((resolve, reject) => {
client.onmessage = resolve;
client.onerror = reject;
client.onclose = () => reject(new Error('child exited before reporting its environment'));
});

await client.start();
const message = await received;
await client.close();

expect(message).toEqual({
jsonrpc: '2.0',
method: 'env',
params: { keys: ['Path'], explicit: true }
});
});

test('should respect custom maxBufferSize option', async () => {
const client = new StdioClientTransport({
command: 'node',
Expand Down
Loading