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/stdio-windows-env-case-precedence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Honour an explicit `Path` (or any non-`PATH` casing) passed in `StdioServerParameters.env` on Windows. Windows environment variables are case-insensitive but an object spread is not, so the inherited `PATH` and the caller's `Path` both survived the merge; Node's `child_process` then resolved the duplicate in favour of the inherited value and the configured one never reached the server process.
43 changes: 39 additions & 4 deletions packages/client/src/client/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@ export const DEFAULT_INHERITED_ENV_VARS =
: /* list inspired by the default env inheritance of sudo */
['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];

/**
* Merges the inherited default environment with the caller-supplied one.
*
* Windows environment variables are case-insensitive, but an object spread is not:
* the `PATH` produced by {@linkcode getDefaultEnvironment} and a caller's `Path`
* survive as two separate keys. Node's `child_process` then resolves the duplicate
* by taking the first in sorted order, so the inherited `PATH` reaches the child and
* the caller's value is silently dropped. `Path` is the casing Windows itself uses,
* so callers hit this without doing anything unusual.
*
* On Windows an inherited default is therefore dropped when the caller supplied the
* same variable under any casing. Elsewhere the two names are genuinely different
* variables and both are kept.
*/
export function mergeDefaultEnvironment(
defaultEnv: Record<string, string>,
env: Record<string, string> | undefined
): Record<string, string> {
if (env === undefined) {
return { ...defaultEnv };
}

if (process.platform !== 'win32') {
return { ...defaultEnv, ...env };
}

const overridden = new Set(Object.keys(env).map(key => key.toUpperCase()));
const merged: Record<string, string> = {};

for (const [key, value] of Object.entries(defaultEnv)) {
if (!overridden.has(key.toUpperCase())) {
merged[key] = value;
}
}

return { ...merged, ...env };
}

/**
* Returns a default environment object including only environment variables deemed safe to inherit.
*/
Expand Down Expand Up @@ -135,10 +173,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: mergeDefaultEnvironment(getDefaultEnvironment(), this._serverParams.env),
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
shell: false,
windowsHide: process.platform === 'win32',
Expand Down
54 changes: 53 additions & 1 deletion packages/client/test/client/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';

import type { StdioServerParameters } from '../../src/client/stdio';
import { DEFAULT_INHERITED_ENV_VARS, StdioClientTransport } from '../../src/client/stdio';
import { DEFAULT_INHERITED_ENV_VARS, mergeDefaultEnvironment, StdioClientTransport } from '../../src/client/stdio';

// Configure default server parameters based on OS
// Uses 'more' command for Windows and 'tee' command for Unix/Linux
Expand Down Expand Up @@ -181,3 +181,55 @@ test('DEFAULT_INHERITED_ENV_VARS matches the host platform', () => {
expect(DEFAULT_INHERITED_ENV_VARS).toEqual(['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']);
}
});

describe('mergeDefaultEnvironment', () => {
const originalPlatform = process.platform;

const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', { value });
};

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

test('returns a copy of the defaults when no environment is given', () => {
setPlatform('win32');
const defaults = { PATH: 'C:\\inherited' };

const merged = mergeDefaultEnvironment(defaults, undefined);

expect(merged).toEqual(defaults);
expect(merged).not.toBe(defaults);
});

test('on Windows, an explicit Path replaces the inherited PATH', () => {
setPlatform('win32');

const merged = mergeDefaultEnvironment({ PATH: 'C:\\inherited', SYSTEMROOT: 'C:\\Windows' }, { Path: 'C:\\explicit-only' });

// Both keys surviving is the bug: Node resolves the duplicate by sorted
// order, so the inherited PATH would win and the caller's Path is dropped.
expect(Object.keys(merged).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path']);
expect(merged.Path).toBe('C:\\explicit-only');
expect(merged.SYSTEMROOT).toBe('C:\\Windows');
});

test('on Windows, an exact-case override still wins and defaults are otherwise kept', () => {
setPlatform('win32');

const merged = mergeDefaultEnvironment({ PATH: 'C:\\inherited', TEMP: 'C:\\Temp' }, { PATH: 'C:\\explicit', EXTRA: '1' });

expect(merged).toEqual({ PATH: 'C:\\explicit', TEMP: 'C:\\Temp', EXTRA: '1' });
});

test('off Windows, names differing only in case stay separate variables', () => {
setPlatform('linux');

const merged = mergeDefaultEnvironment({ PATH: '/inherited' }, { Path: '/explicit' });

// POSIX environment variables are case-sensitive, so these are two
// different variables and dropping either would be wrong.
expect(merged).toEqual({ PATH: '/inherited', Path: '/explicit' });
});
});
Loading