From c99b0b25e91cda999e214dcaa848dc43447c2ce3 Mon Sep 17 00:00:00 2001 From: ohad6k <190882507+ohad6k@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:10:54 +0300 Subject: [PATCH] fix(client): let an explicit env var replace the inherited default in any casing on Windows Fixes #2859. Co-Authored-By: Claude Opus 5.5 --- .changeset/fix-stdio-windows-env-casing.md | 5 ++ src/client/stdio.ts | 31 +++++++++++-- test/client/cross-spawn.test.ts | 54 ++++++++++++++++++++++ test/client/stdio.test.ts | 28 +++++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-stdio-windows-env-casing.md diff --git a/.changeset/fix-stdio-windows-env-casing.md b/.changeset/fix-stdio-windows-env-casing.md new file mode 100644 index 0000000000..ed8dacaa8e --- /dev/null +++ b/.changeset/fix-stdio-windows-env-casing.md @@ -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`. diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 816a686fce..ed3b672d64 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -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; @@ -92,6 +93,29 @@ export function getDefaultEnvironment(): Record { 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, customEnv?: Record): Record { + if (process.platform !== 'win32' || !customEnv) { + return { ...defaultEnv, ...customEnv }; + } + + const customKeys = new Set(Object.keys(customEnv).map(key => key.toUpperCase())); + const env: Record = {}; + 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. * @@ -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', diff --git a/test/client/cross-spawn.test.ts b/test/client/cross-spawn.test.ts index 5a26711952..03b8ae1496 100644 --- a/test/client/cross-spawn.test.ts +++ b/test/client/cross-spawn.test.ts @@ -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 => { + const options = mockSpawn.mock.calls[0]?.[2] as { env: Record }; + 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' diff --git a/test/client/stdio.test.ts b/test/client/stdio.test.ts index 577e0c946f..09431e4316 100644 --- a/test/client/stdio.test.ts +++ b/test/client/stdio.test.ts @@ -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((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',