From 36ee0da9a6963c6c9754ac88be7e2f3cc1cd44c7 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 22:34:26 -0700 Subject: [PATCH 1/8] Add InheritEnvironmentVariables to StdioClientTransportOptions Introduces a new bool property InheritEnvironmentVariables (default: true) on StdioClientTransportOptions that controls whether the child server process inherits the current process's environment variables. When false, startInfo.Environment.Clear() is called before any EnvironmentVariables entries are applied, giving the child process a completely clean environment. This allows callers to prevent unintentional leakage of credentials, tokens, and proxy settings into third-party or untrusted MCP server processes. The new property is backward-compatible: the default (true) preserves the existing behavior of inheriting all parent env vars and then layering EnvironmentVariables on top. Three tests are added to StdioClientTransportTests: - InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars - InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars - InheritEnvironmentVariables_False_WithExplicitVars_ChildSeesOnlyExplicitVars Documentation in transports.md is updated with: - New property in the stdio options table - A dedicated 'Environment variable inheritance' section with a code sample and a WARNING callout explaining both the security risk of inheriting (credential leakage) and the compatibility risk of disabling (PATH, HOME, DOTNET_ROOT etc. required by many tools). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 26 +++- .../Client/StdioClientTransport.cs | 5 + .../Client/StdioClientTransportOptions.cs | 49 +++++- .../Transport/StdioClientTransportTests.cs | 145 ++++++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index d0d885114..adc5ddb82 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -39,11 +39,35 @@ Key properties: | `Command` | The executable to launch (required) | | `Arguments` | Command-line arguments for the process | | `WorkingDirectory` | Working directory for the server process | -| `EnvironmentVariables` | Environment variables (merged with current; `null` values remove variables) | +| `EnvironmentVariables` | Environment variables (merged with current when inheriting; `null` values remove variables) | +| `InheritEnvironmentVariables` | Whether the server process inherits the current process's environment variables (default: `true`) | | `ShutdownTimeout` | Graceful shutdown timeout (default: 5 seconds) | | `StandardErrorLines` | Callback for stderr output from the server process | | `Name` | Optional transport identifier for logging | +#### Environment variable inheritance + +By default, the server process inherits **all** environment variables from the current process. This includes credentials, tokens, proxy settings, and internal configuration that may be sensitive or irrelevant to the server. When running third-party or untrusted MCP servers, consider disabling inheritance to prevent unintentional credential leakage: + +```csharp +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = "my-mcp-server", + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary + { + // Provide only the variables the server actually needs. + ["PATH"] = Environment.GetEnvironmentVariable("PATH"), + ["MY_SERVER_API_KEY"] = apiKey, + } +}); +``` + +> [!WARNING] +> **Security risk (inheriting):** Variables such as `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `OPENAI_API_KEY`, and similar credentials present in the parent process automatically flow into the child process unless inheritance is disabled. This can unintentionally expose sensitive values to third-party or untrusted MCP servers. +> +> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. Common requirements include `PATH` (to locate executables), `HOME` (used by many tools on Unix), `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, and proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`). When disabling inheritance, ensure all variables required by the server are explicitly supplied via `EnvironmentVariables`. + #### stdio server Use for servers that communicate over stdin/stdout: diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs index 24a7dba8e..8f57d462b 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransport.cs @@ -111,6 +111,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = #endif } + if (!_options.InheritEnvironmentVariables) + { + startInfo.Environment.Clear(); + } + if (_options.EnvironmentVariables != null) { foreach (var entry in _options.EnvironmentVariables) diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs index 2d6df08b4..c3d8167c5 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs @@ -38,6 +38,43 @@ public required string Command /// public string? WorkingDirectory { get; set; } + /// + /// Gets or sets a value indicating whether the server process should inherit the current process's environment variables. + /// + /// + /// to inherit the current process's environment variables (the default); + /// to start the server process with an empty environment and only the variables explicitly provided via + /// . + /// + /// + /// + /// When (the default), the server process starts with all of the current process's environment + /// variables. Any entries in are then applied on top, adding or overwriting inherited + /// variables. + /// + /// + /// When , the server process starts with a completely empty environment. The + /// dictionary is the sole source of environment variables for the child process. This is useful when you want to minimize + /// the attack surface by preventing credentials, tokens, proxy settings, and other sensitive values present in the current + /// environment from unintentionally reaching the child process. + /// + /// + /// Security consideration: Inheriting environment variables (the default) can unintentionally expose + /// sensitive values to the child process. Variables such as AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, + /// OPENAI_API_KEY, and similar credentials that are present in the parent process will automatically flow into + /// the server process, which may be undesirable when running third-party or untrusted MCP servers. + /// + /// + /// Compatibility consideration: Disabling inheritance can cause the child process to fail to start or + /// behave unexpectedly if it relies on variables provided by the operating system or the user's shell environment. Common + /// examples include PATH (required to locate executables), HOME (required by many tools on Unix), + /// DOTNET_ROOT, LD_LIBRARY_PATH, JAVA_HOME, and proxy settings (HTTP_PROXY, + /// HTTPS_PROXY, NO_PROXY). When disabling inheritance, ensure that all variables required by the server + /// process are explicitly provided via . + /// + /// + public bool InheritEnvironmentVariables { get; set; } = true; + /// /// Gets or sets environment variables to set for the server process. /// @@ -48,10 +85,14 @@ public required string Command /// to the server without modifying its code. /// /// - /// By default, when starting the server process, the server process will inherit the current environment's variables, - /// as discovered via . After those variables are found, the entries - /// in this dictionary are used to augment and overwrite the entries read from the environment. - /// That includes removing the variables for any of this collection's entries with a null value. + /// When is (the default), the server process starts with + /// all environment variables inherited from the current process. The entries in this + /// dictionary are then applied on top: adding new variables, overwriting inherited ones, or removing variables whose + /// value is set to . + /// + /// + /// When is , the server process starts with an empty + /// environment. This dictionary is the sole source of environment variables for the child process. /// /// public IDictionary? EnvironmentVariables { get; set; } diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 1a999fd14..7417435fa 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -150,6 +150,151 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) Assert.Equal(cliArgumentValue ?? "", content.Text); } + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars() + { + string varName = $"MCP_TEST_{Guid.NewGuid():N}"; + string varValue = Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(varName, varValue); + try + { + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", $"echo %{varName}% >&2 & exit /b 1"], + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", $"echo \"${{{varName}}}\" >&2; exit 1"], + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); + + Assert.Contains(varValue, capturedLine); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars() + { + string varName = $"MCP_TEST_{Guid.NewGuid():N}"; + Environment.SetEnvironmentVariable(varName, "SHOULD_NOT_APPEAR"); + try + { + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", $"if defined {varName} (echo FOUND >&2) else (echo NOT_FOUND >&2) & exit /b 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", $"if [ -n \"${{{varName}}}\" ]; then echo FOUND >&2; else echo NOT_FOUND >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); + + Assert.Equal("NOT_FOUND", capturedLine.Trim()); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task InheritEnvironmentVariables_False_WithExplicitVars_ChildSeesOnlyExplicitVars() + { + string inheritedVarName = $"MCP_TEST_INHERITED_{Guid.NewGuid():N}"; + string explicitVarName = $"MCP_TEST_EXPLICIT_{Guid.NewGuid():N}"; + string explicitVarValue = Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(inheritedVarName, "SHOULD_NOT_APPEAR"); + try + { + var capturedLines = new List(); + var lineCount = 0; + var tcs = new TaskCompletionSource(); + void CaptureLines(string line) + { + lock (capturedLines) + { + capturedLines.Add(line.Trim()); + if (Interlocked.Increment(ref lineCount) >= 2) + { + tcs.TrySetResult(true); + } + } + } + + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", + $"if defined {inheritedVarName} (echo INHERITED_FOUND >&2) else (echo INHERITED_NOT_FOUND >&2) " + + $"& if defined {explicitVarName} (echo EXPLICIT_FOUND >&2) else (echo EXPLICIT_NOT_FOUND >&2) " + + $"& exit /b 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary + { + ["PATH"] = Environment.GetEnvironmentVariable("PATH"), + [explicitVarName] = explicitVarValue + }, + StandardErrorLines = CaptureLines + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", + $"if [ -n \"${{{inheritedVarName}}}\" ]; then echo INHERITED_FOUND >&2; else echo INHERITED_NOT_FOUND >&2; fi; " + + $"if [ -n \"${{{explicitVarName}}}\" ]; then echo EXPLICIT_FOUND >&2; else echo EXPLICIT_NOT_FOUND >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary + { + ["PATH"] = Environment.GetEnvironmentVariable("PATH"), + [explicitVarName] = explicitVarValue + }, + StandardErrorLines = CaptureLines + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + await tcs.Task.WaitAsync(cts.Token); + + string allOutput = string.Join(Environment.NewLine, capturedLines); + Assert.Contains("INHERITED_NOT_FOUND", allOutput); + Assert.Contains("EXPLICIT_FOUND", allOutput); + } + finally + { + Environment.SetEnvironmentVariable(inheritedVarName, null); + } + } + [Fact] public async Task SendMessageAsync_Should_Use_LF_Not_CRLF() { From d3dd5189e064723d32d191796b2f4d828dcd3b7d Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 22:53:24 -0700 Subject: [PATCH 2/8] Refactor InheritEnvironmentVariables tests to avoid modifying parent env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of calling Environment.SetEnvironmentVariable (which is process-global and can cause race conditions with parallel tests), rely on PATH which is always present in a real process environment as the 'known parent variable'. For the no-inherit tests, use absolute shell paths (/bin/sh on Unix, full cmd.exe path on Windows from SystemRoot) so the child can launch even with a completely empty environment — no PATH needed. Test 1: default inheritance → child sees PATH (no parent env mutation) Test 2: InheritEnvironmentVariables=false → child does NOT see PATH Test 3: InheritEnvironmentVariables=false + explicit var → PATH absent, explicit MCP_STDIO_TEST_EXPLICIT_VAR is present Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Transport/StdioClientTransportTests.cs | 210 ++++++++---------- 1 file changed, 92 insertions(+), 118 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 7417435fa..c1c382e26 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -153,146 +153,120 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars() { - string varName = $"MCP_TEST_{Guid.NewGuid():N}"; - string varValue = Guid.NewGuid().ToString("N"); - Environment.SetEnvironmentVariable(varName, varValue); - try - { - var tcs = new TaskCompletionSource(); - StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - new(new() - { - Command = "cmd", - Arguments = ["/c", $"echo %{varName}% >&2 & exit /b 1"], - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory) : - new(new() - { - Command = "sh", - Arguments = ["-c", $"echo \"${{{varName}}}\" >&2; exit 1"], - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory); + // PATH is always set in a real process environment. Use it as a reliable + // indicator that env vars were inherited without modifying the parent process. + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = "cmd", + Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory) : + new(new() + { + Command = "sh", + Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory); - await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); - using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); - string capturedLine = await tcs.Task.WaitAsync(cts.Token); + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); - Assert.Contains(varValue, capturedLine); - } - finally - { - Environment.SetEnvironmentVariable(varName, null); - } + Assert.Equal("PATH_IS_SET", capturedLine.Trim()); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars() { - string varName = $"MCP_TEST_{Guid.NewGuid():N}"; - Environment.SetEnvironmentVariable(varName, "SHOULD_NOT_APPEAR"); - try - { - var tcs = new TaskCompletionSource(); - StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - new(new() - { - Command = "cmd", - Arguments = ["/c", $"if defined {varName} (echo FOUND >&2) else (echo NOT_FOUND >&2) & exit /b 1"], - InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory) : - new(new() - { - Command = "sh", - Arguments = ["-c", $"if [ -n \"${{{varName}}}\" ]; then echo FOUND >&2; else echo NOT_FOUND >&2; fi; exit 1"], - InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory); + // Use absolute shell paths so the child can be launched even with an empty environment. + // Verify that PATH (always set in the parent) is absent in the child when inheritance is disabled. + var tcs = new TaskCompletionSource(); + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = Path.Combine( + Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows", + "System32", "cmd.exe"), + Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], + InheritEnvironmentVariables = false, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory) : + new(new() + { + Command = "/bin/sh", + Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + StandardErrorLines = line => tcs.TrySetResult(line) + }, LoggerFactory); - await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); - using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); - string capturedLine = await tcs.Task.WaitAsync(cts.Token); + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + string capturedLine = await tcs.Task.WaitAsync(cts.Token); - Assert.Equal("NOT_FOUND", capturedLine.Trim()); - } - finally - { - Environment.SetEnvironmentVariable(varName, null); - } + Assert.Equal("PATH_NOT_SET", capturedLine.Trim()); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_False_WithExplicitVars_ChildSeesOnlyExplicitVars() { - string inheritedVarName = $"MCP_TEST_INHERITED_{Guid.NewGuid():N}"; - string explicitVarName = $"MCP_TEST_EXPLICIT_{Guid.NewGuid():N}"; - string explicitVarValue = Guid.NewGuid().ToString("N"); - Environment.SetEnvironmentVariable(inheritedVarName, "SHOULD_NOT_APPEAR"); - try + // Use absolute shell paths so the child can be launched even with an empty environment. + // Verify that: (1) PATH (always in parent) is absent because it was not explicitly provided, + // (2) an explicitly provided variable IS visible in the child. + const string explicitVarName = "MCP_STDIO_TEST_EXPLICIT_VAR"; + const string explicitVarValue = "explicit_test_value"; + + var capturedLines = new List(); + var lineCount = 0; + var tcs = new TaskCompletionSource(); + void CaptureLines(string line) { - var capturedLines = new List(); - var lineCount = 0; - var tcs = new TaskCompletionSource(); - void CaptureLines(string line) + lock (capturedLines) { - lock (capturedLines) + capturedLines.Add(line.Trim()); + if (Interlocked.Increment(ref lineCount) >= 2) { - capturedLines.Add(line.Trim()); - if (Interlocked.Increment(ref lineCount) >= 2) - { - tcs.TrySetResult(true); - } + tcs.TrySetResult(true); } } - - StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - new(new() - { - Command = "cmd", - Arguments = ["/c", - $"if defined {inheritedVarName} (echo INHERITED_FOUND >&2) else (echo INHERITED_NOT_FOUND >&2) " + - $"& if defined {explicitVarName} (echo EXPLICIT_FOUND >&2) else (echo EXPLICIT_NOT_FOUND >&2) " + - $"& exit /b 1"], - InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary - { - ["PATH"] = Environment.GetEnvironmentVariable("PATH"), - [explicitVarName] = explicitVarValue - }, - StandardErrorLines = CaptureLines - }, LoggerFactory) : - new(new() - { - Command = "sh", - Arguments = ["-c", - $"if [ -n \"${{{inheritedVarName}}}\" ]; then echo INHERITED_FOUND >&2; else echo INHERITED_NOT_FOUND >&2; fi; " + - $"if [ -n \"${{{explicitVarName}}}\" ]; then echo EXPLICIT_FOUND >&2; else echo EXPLICIT_NOT_FOUND >&2; fi; exit 1"], - InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary - { - ["PATH"] = Environment.GetEnvironmentVariable("PATH"), - [explicitVarName] = explicitVarValue - }, - StandardErrorLines = CaptureLines - }, LoggerFactory); - - await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); - - using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); - await tcs.Task.WaitAsync(cts.Token); - - string allOutput = string.Join(Environment.NewLine, capturedLines); - Assert.Contains("INHERITED_NOT_FOUND", allOutput); - Assert.Contains("EXPLICIT_FOUND", allOutput); - } - finally - { - Environment.SetEnvironmentVariable(inheritedVarName, null); } + + StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + new(new() + { + Command = Path.Combine( + Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows", + "System32", "cmd.exe"), + Arguments = ["/c", + $"if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) " + + $"& if defined {explicitVarName} (echo EXPLICIT_IS_SET >&2) else (echo EXPLICIT_NOT_SET >&2) " + + $"& exit /b 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { [explicitVarName] = explicitVarValue }, + StandardErrorLines = CaptureLines + }, LoggerFactory) : + new(new() + { + Command = "/bin/sh", + Arguments = ["-c", + $"if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; " + + $"if [ -n \"${explicitVarName}\" ]; then echo EXPLICIT_IS_SET >&2; else echo EXPLICIT_NOT_SET >&2; fi; exit 1"], + InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { [explicitVarName] = explicitVarValue }, + StandardErrorLines = CaptureLines + }, LoggerFactory); + + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); + await tcs.Task.WaitAsync(cts.Token); + + string allOutput = string.Join(Environment.NewLine, capturedLines); + Assert.Contains("PATH_NOT_SET", allOutput); + Assert.Contains("EXPLICIT_IS_SET", allOutput); } [Fact] From 57fe209e738c1d2afaa22ba060e741e06c6875d8 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 23:08:41 -0700 Subject: [PATCH 3/8] Simplify InheritEnvironmentVariables tests, use cmd/sh without absolute paths Use PATH (passed explicitly when InheritEnvironmentVariables=false) to find cmd/sh rather than hardcoded absolute paths. Verify inheritance by checking variables that are always present in a real process but deliberately omitted from the explicit EnvironmentVariables dict: HOME (Unix) and USERNAME (Windows). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Transport/StdioClientTransportTests.cs | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index c1c382e26..2fe0cf6e3 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -153,22 +153,12 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars() { - // PATH is always set in a real process environment. Use it as a reliable - // indicator that env vars were inherited without modifying the parent process. + // PATH is always set in a real process environment. Verify the child sees it + // under the default (inherit) behavior without mutating the parent process. var tcs = new TaskCompletionSource(); StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - new(new() - { - Command = "cmd", - Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory) : - new(new() - { - Command = "sh", - Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], - StandardErrorLines = line => tcs.TrySetResult(line) - }, LoggerFactory); + new(new() { Command = "cmd", Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory) : + new(new() { Command = "sh", Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory); await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); @@ -181,24 +171,24 @@ public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars() { - // Use absolute shell paths so the child can be launched even with an empty environment. - // Verify that PATH (always set in the parent) is absent in the child when inheritance is disabled. + // Pass PATH so cmd/sh can be located. Verify that HOME (Unix) / USERNAME (Windows), + // which are always set in the parent, are absent because they were not explicitly provided. var tcs = new TaskCompletionSource(); StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new(new() { - Command = Path.Combine( - Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows", - "System32", "cmd.exe"), - Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], + Command = "cmd", + Arguments = ["/c", "if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) & exit /b 1"], InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory) : new(new() { - Command = "/bin/sh", - Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], + Command = "sh", + Arguments = ["-c", "if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; exit 1"], InheritEnvironmentVariables = false, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") }, StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory); @@ -207,15 +197,15 @@ public async Task InheritEnvironmentVariables_False_ChildDoesNotSeeParentEnvVars using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); string capturedLine = await tcs.Task.WaitAsync(cts.Token); - Assert.Equal("PATH_NOT_SET", capturedLine.Trim()); + // HOME / USERNAME were in the parent but not passed — should be absent in the child. + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_NOT_SET" : "HOME_NOT_SET", capturedLine.Trim()); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_False_WithExplicitVars_ChildSeesOnlyExplicitVars() { - // Use absolute shell paths so the child can be launched even with an empty environment. - // Verify that: (1) PATH (always in parent) is absent because it was not explicitly provided, - // (2) an explicitly provided variable IS visible in the child. + // Pass PATH + one explicit var. Verify HOME (Unix) / USERNAME (Windows) is absent, + // and the explicitly provided variable is visible. const string explicitVarName = "MCP_STDIO_TEST_EXPLICIT_VAR"; const string explicitVarValue = "explicit_test_value"; @@ -228,34 +218,30 @@ void CaptureLines(string line) { capturedLines.Add(line.Trim()); if (Interlocked.Increment(ref lineCount) >= 2) - { tcs.TrySetResult(true); - } } } StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new(new() { - Command = Path.Combine( - Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows", - "System32", "cmd.exe"), + Command = "cmd", Arguments = ["/c", - $"if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) " + + $"if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) " + $"& if defined {explicitVarName} (echo EXPLICIT_IS_SET >&2) else (echo EXPLICIT_NOT_SET >&2) " + $"& exit /b 1"], InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary { [explicitVarName] = explicitVarValue }, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), [explicitVarName] = explicitVarValue }, StandardErrorLines = CaptureLines }, LoggerFactory) : new(new() { - Command = "/bin/sh", + Command = "sh", Arguments = ["-c", - $"if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; " + + $"if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; " + $"if [ -n \"${explicitVarName}\" ]; then echo EXPLICIT_IS_SET >&2; else echo EXPLICIT_NOT_SET >&2; fi; exit 1"], InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary { [explicitVarName] = explicitVarValue }, + EnvironmentVariables = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), [explicitVarName] = explicitVarValue }, StandardErrorLines = CaptureLines }, LoggerFactory); @@ -265,7 +251,7 @@ void CaptureLines(string line) await tcs.Task.WaitAsync(cts.Token); string allOutput = string.Join(Environment.NewLine, capturedLines); - Assert.Contains("PATH_NOT_SET", allOutput); + Assert.Contains(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_NOT_SET" : "HOME_NOT_SET", allOutput); Assert.Contains("EXPLICIT_IS_SET", allOutput); } From 9ce7245b7c8d00801947f0a92fc65d7028d4ed03 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 23:12:36 -0700 Subject: [PATCH 4/8] Update samples to use InheritEnvironmentVariables=false with minimal env Both QuickstartClient and ChatWithTools now disable environment variable inheritance and forward only the variables their server processes require: PATH, HOME/USERPROFILE, APPDATA, LOCALAPPDATA, TEMP/TMPDIR for Node/npm; plus DOTNET_ROOT and NUGET_PACKAGES for the dotnet case. This prevents credentials, tokens, and other sensitive parent-process variables from leaking into third-party MCP server processes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/ChatWithTools/Program.cs | 17 +++++++++++++++++ samples/QuickstartClient/Program.cs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/samples/ChatWithTools/Program.cs b/samples/ChatWithTools/Program.cs index c5870cdc3..61698ee44 100644 --- a/samples/ChatWithTools/Program.cs +++ b/samples/ChatWithTools/Program.cs @@ -7,6 +7,7 @@ using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; +using System.Runtime.InteropServices; using var tracerProvider = Sdk.CreateTracerProviderBuilder() .AddHttpClientInstrumentation() @@ -39,6 +40,8 @@ Command = "npx", Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"], Name = "Everything", + InheritEnvironmentVariables = false, + EnvironmentVariables = MinimalEnvironment(), }), clientOptions: new() { @@ -82,4 +85,18 @@ Console.WriteLine(); messages.AddMessages(updates); +} + +// Returns a minimal set of environment variables needed by Node.js/npm tooling. +// Omitting variables the server doesn't need prevents unintentional leakage of +// credentials or other sensitive values present in the parent process. +static Dictionary MinimalEnvironment() +{ + string[] names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ["PATH", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP"] + : ["PATH", "HOME", "TMPDIR"]; + return names + .Select(n => (n, v: Environment.GetEnvironmentVariable(n))) + .Where(t => t.v is not null) + .ToDictionary(t => t.n, t => (string?)t.v); } \ No newline at end of file diff --git a/samples/QuickstartClient/Program.cs b/samples/QuickstartClient/Program.cs index 685d8acff..72a77ba11 100644 --- a/samples/QuickstartClient/Program.cs +++ b/samples/QuickstartClient/Program.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Client; using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; var builder = Host.CreateApplicationBuilder(args); @@ -31,6 +32,8 @@ Name = "Demo Server", Command = command, Arguments = arguments, + InheritEnvironmentVariables = false, + EnvironmentVariables = MinimalEnvironment(), }); } await using var mcpClient = await McpClient.CreateAsync(clientTransport!); @@ -122,3 +125,17 @@ static string GetCurrentSourceDirectory([CallerFilePath] string? currentFile = n Debug.Assert(!string.IsNullOrWhiteSpace(currentFile)); return Path.GetDirectoryName(currentFile) ?? throw new InvalidOperationException("Unable to determine source directory."); } + +// Returns a minimal set of environment variables needed by dotnet/node/python tooling. +// Omitting variables the server doesn't need prevents unintentional leakage of +// credentials or other sensitive values present in the parent process. +static Dictionary MinimalEnvironment() +{ + string[] names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ["PATH", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "DOTNET_ROOT", "NUGET_PACKAGES"] + : ["PATH", "HOME", "TMPDIR", "DOTNET_ROOT", "NUGET_PACKAGES"]; + return names + .Select(n => (n, v: Environment.GetEnvironmentVariable(n))) + .Where(t => t.v is not null) + .ToDictionary(t => t.n, t => (string?)t.v); +} From 94a2dff098e405fa4f36841418bf381465cd4de5 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 23:32:05 -0700 Subject: [PATCH 5/8] Make InheritEnvironmentVariables_DefaultTrue test use same var as False test Both DefaultTrue and False tests now check HOME (Unix) / USERNAME (Windows) so they form a symmetric pair asserting opposite outcomes on the same signal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Transport/StdioClientTransportTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 2fe0cf6e3..41e6c7a6c 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -153,19 +153,19 @@ public async Task EscapesCliArgumentsCorrectly(string? cliArgumentValue) [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task InheritEnvironmentVariables_DefaultTrue_ChildSeesParentEnvVars() { - // PATH is always set in a real process environment. Verify the child sees it - // under the default (inherit) behavior without mutating the parent process. + // Check the same variable the False test checks for absence (HOME on Unix, USERNAME on Windows) + // so the two tests form a direct symmetric pair: one asserts it IS set, the other asserts it is NOT. var tcs = new TaskCompletionSource(); StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? - new(new() { Command = "cmd", Arguments = ["/c", "if defined PATH (echo PATH_IS_SET >&2) else (echo PATH_NOT_SET >&2) & exit /b 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory) : - new(new() { Command = "sh", Arguments = ["-c", "if [ -n \"$PATH\" ]; then echo PATH_IS_SET >&2; else echo PATH_NOT_SET >&2; fi; exit 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory); + new(new() { Command = "cmd", Arguments = ["/c", "if defined USERNAME (echo USERNAME_IS_SET >&2) else (echo USERNAME_NOT_SET >&2) & exit /b 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory) : + new(new() { Command = "sh", Arguments = ["-c", "if [ -n \"$HOME\" ]; then echo HOME_IS_SET >&2; else echo HOME_NOT_SET >&2; fi; exit 1"], StandardErrorLines = line => tcs.TrySetResult(line) }, LoggerFactory); await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); using var cts = new CancellationTokenSource(TestConstants.DefaultTimeout); string capturedLine = await tcs.Task.WaitAsync(cts.Token); - Assert.Equal("PATH_IS_SET", capturedLine.Trim()); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERNAME_IS_SET" : "HOME_IS_SET", capturedLine.Trim()); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] From b930b3eaafa0d561118e70935afa634943f72fbd Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 23:53:04 -0700 Subject: [PATCH 6/8] Add StdioClientTransportOptions.GetDefaultEnvironmentVariables() helper Returns a curated allowlist of env vars (PATH, HOME, system dirs, etc.) safe to forward to child processes, aligned with the TypeScript and Python MCP SDK defaults. Replaces the duplicated MinimalEnvironment() helpers in the samples and updates transports.md to demonstrate the new API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 21 +++-- samples/ChatWithTools/Program.cs | 16 +--- samples/QuickstartClient/Program.cs | 25 +++--- .../Client/StdioClientTransportOptions.cs | 83 +++++++++++++++++++ 4 files changed, 113 insertions(+), 32 deletions(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index adc5ddb82..ba0acd20c 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -54,12 +54,21 @@ var transport = new StdioClientTransport(new StdioClientTransportOptions { Command = "my-mcp-server", InheritEnvironmentVariables = false, - EnvironmentVariables = new Dictionary - { - // Provide only the variables the server actually needs. - ["PATH"] = Environment.GetEnvironmentVariable("PATH"), - ["MY_SERVER_API_KEY"] = apiKey, - } + EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(), +}); +``` + +`GetDefaultEnvironmentVariables()` returns a curated set of environment variables (such as `PATH`, `HOME`, and standard system directories) that most child processes need to start correctly, without leaking credentials or other sensitive values from the parent process. The allowlist is aligned with the defaults used by the TypeScript and Python MCP SDKs. You can add server-specific variables on top: + +```csharp +var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); +env["MY_SERVER_API_KEY"] = apiKey; + +var transport = new StdioClientTransport(new StdioClientTransportOptions +{ + Command = "my-mcp-server", + InheritEnvironmentVariables = false, + EnvironmentVariables = env, }); ``` diff --git a/samples/ChatWithTools/Program.cs b/samples/ChatWithTools/Program.cs index 61698ee44..98cf24166 100644 --- a/samples/ChatWithTools/Program.cs +++ b/samples/ChatWithTools/Program.cs @@ -7,7 +7,6 @@ using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -using System.Runtime.InteropServices; using var tracerProvider = Sdk.CreateTracerProviderBuilder() .AddHttpClientInstrumentation() @@ -41,7 +40,7 @@ Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"], Name = "Everything", InheritEnvironmentVariables = false, - EnvironmentVariables = MinimalEnvironment(), + EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(), }), clientOptions: new() { @@ -87,16 +86,3 @@ messages.AddMessages(updates); } -// Returns a minimal set of environment variables needed by Node.js/npm tooling. -// Omitting variables the server doesn't need prevents unintentional leakage of -// credentials or other sensitive values present in the parent process. -static Dictionary MinimalEnvironment() -{ - string[] names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? ["PATH", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP"] - : ["PATH", "HOME", "TMPDIR"]; - return names - .Select(n => (n, v: Environment.GetEnvironmentVariable(n))) - .Where(t => t.v is not null) - .ToDictionary(t => t.n, t => (string?)t.v); -} \ No newline at end of file diff --git a/samples/QuickstartClient/Program.cs b/samples/QuickstartClient/Program.cs index 72a77ba11..c5d156126 100644 --- a/samples/QuickstartClient/Program.cs +++ b/samples/QuickstartClient/Program.cs @@ -5,7 +5,6 @@ using ModelContextProtocol.Client; using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Text; var builder = Host.CreateApplicationBuilder(args); @@ -33,7 +32,7 @@ Command = command, Arguments = arguments, InheritEnvironmentVariables = false, - EnvironmentVariables = MinimalEnvironment(), + EnvironmentVariables = MinimalDotNetEnvironment(), }); } await using var mcpClient = await McpClient.CreateAsync(clientTransport!); @@ -126,16 +125,20 @@ static string GetCurrentSourceDirectory([CallerFilePath] string? currentFile = n return Path.GetDirectoryName(currentFile) ?? throw new InvalidOperationException("Unable to determine source directory."); } -// Returns a minimal set of environment variables needed by dotnet/node/python tooling. +// Returns the safe default environment variables plus extras needed by 'dotnet run'. // Omitting variables the server doesn't need prevents unintentional leakage of // credentials or other sensitive values present in the parent process. -static Dictionary MinimalEnvironment() +static Dictionary MinimalDotNetEnvironment() { - string[] names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? ["PATH", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "DOTNET_ROOT", "NUGET_PACKAGES"] - : ["PATH", "HOME", "TMPDIR", "DOTNET_ROOT", "NUGET_PACKAGES"]; - return names - .Select(n => (n, v: Environment.GetEnvironmentVariable(n))) - .Where(t => t.v is not null) - .ToDictionary(t => t.n, t => (string?)t.v); + var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + // 'dotnet run' also needs DOTNET_ROOT and NUGET_PACKAGES to find the .NET runtime and package cache. + foreach (var key in (string[])["DOTNET_ROOT", "NUGET_PACKAGES"]) + { + var value = Environment.GetEnvironmentVariable(key); + if (value is not null) + { + env[key] = value; + } + } + return env; } diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs index c3d8167c5..8013f5316 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace ModelContextProtocol.Client; /// @@ -5,6 +7,87 @@ namespace ModelContextProtocol.Client; /// public sealed class StdioClientTransportOptions { + // Platform-appropriate allowlists, aligned with the TypeScript and Python MCP SDK defaults. + // TypeScript adds PROGRAMFILES; Python adds PATHEXT. Both are included here. + private static readonly string[] s_defaultWindowsVars = + [ + "APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PATHEXT", + "PROCESSOR_ARCHITECTURE", "PROGRAMFILES", "SYSTEMDRIVE", "SYSTEMROOT", + "TEMP", "USERNAME", "USERPROFILE", + ]; + + private static readonly string[] s_defaultUnixVars = + [ + "HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", + ]; + + /// + /// Returns a curated set of environment variables from the current process that are safe to forward to a child + /// MCP server process. + /// + /// + /// A new populated with the subset of the current process's environment + /// variables that most child processes need to start correctly — for example PATH, HOME, and + /// standard system directories. Values that appear to be shell function definitions (those starting with + /// ()) are excluded for security reasons. + /// + /// + /// + /// The allowlist is aligned with the defaults used by the TypeScript and Python MCP SDKs. On Windows it + /// includes: APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, + /// PATHEXT, PROCESSOR_ARCHITECTURE, PROGRAMFILES, SYSTEMDRIVE, + /// SYSTEMROOT, TEMP, USERNAME, and USERPROFILE. On Unix/macOS it includes: + /// HOME, LOGNAME, PATH, SHELL, TERM, and USER. + /// + /// + /// This method is designed to be used together with set to + /// . Pass the returned dictionary as , optionally + /// adding any server-specific variables the server requires: + /// + /// var env = StdioClientTransportOptions.GetDefaultEnvironmentVariables(); + /// env["MY_SERVER_API_KEY"] = apiKey; + /// + /// var transport = new StdioClientTransport(new StdioClientTransportOptions + /// { + /// Command = "my-mcp-server", + /// InheritEnvironmentVariables = false, + /// EnvironmentVariables = env, + /// }); + /// + /// + /// + /// If the server requires additional variables not in the default set (such as DOTNET_ROOT, + /// JAVA_HOME, or proxy settings), add them explicitly after calling this method. + /// + /// + public static Dictionary GetDefaultEnvironmentVariables() + { + var names = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? s_defaultWindowsVars + : s_defaultUnixVars; + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var name in names) + { + var value = Environment.GetEnvironmentVariable(name); + if (value is null) + { + continue; + } + + if (value.StartsWith("()", StringComparison.Ordinal)) + { + // Skip shell function definitions — they are a security risk. + continue; + } + + result[name] = value; + } + + return result; + } + + /// /// Gets or sets the command to execute to start the server process. /// From d6505bb4682542eb45c493bba692220caf3b4560 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 5 May 2026 23:56:35 -0700 Subject: [PATCH 7/8] Point compatibility warnings at GetDefaultEnvironmentVariables() in docs and XML Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/transports/transports.md | 2 +- .../Client/StdioClientTransportOptions.cs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index ba0acd20c..6bb362170 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -75,7 +75,7 @@ var transport = new StdioClientTransport(new StdioClientTransportOptions > [!WARNING] > **Security risk (inheriting):** Variables such as `AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, `OPENAI_API_KEY`, and similar credentials present in the parent process automatically flow into the child process unless inheritance is disabled. This can unintentionally expose sensitive values to third-party or untrusted MCP servers. > -> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. Common requirements include `PATH` (to locate executables), `HOME` (used by many tools on Unix), `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, and proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`). When disabling inheritance, ensure all variables required by the server are explicitly supplied via `EnvironmentVariables`. +> **Compatibility risk (not inheriting):** Disabling inheritance can cause the child process to fail to start or behave incorrectly if it relies on variables provided by the OS or shell. `GetDefaultEnvironmentVariables()` covers the most common requirements — `PATH`, `HOME`, and standard system directories — so for most servers it is a safe starting point. For servers that need additional variables not in the default set (such as `DOTNET_ROOT`, `LD_LIBRARY_PATH`, `JAVA_HOME`, or proxy settings like `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`), add them on top as shown in the example above. #### stdio server diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs index 8013f5316..1ee4d6988 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs @@ -149,11 +149,12 @@ public required string Command /// /// /// Compatibility consideration: Disabling inheritance can cause the child process to fail to start or - /// behave unexpectedly if it relies on variables provided by the operating system or the user's shell environment. Common - /// examples include PATH (required to locate executables), HOME (required by many tools on Unix), - /// DOTNET_ROOT, LD_LIBRARY_PATH, JAVA_HOME, and proxy settings (HTTP_PROXY, - /// HTTPS_PROXY, NO_PROXY). When disabling inheritance, ensure that all variables required by the server - /// process are explicitly provided via . + /// behave unexpectedly if it relies on variables provided by the operating system or the user's shell environment. + /// covers the most common requirements — PATH, HOME, and + /// standard system directories — and is a safe starting point for most servers. For servers that also need variables + /// outside that set (such as DOTNET_ROOT, LD_LIBRARY_PATH, JAVA_HOME, or proxy settings like + /// HTTP_PROXY, HTTPS_PROXY, and NO_PROXY), add them explicitly via + /// after calling . /// /// public bool InheritEnvironmentVariables { get; set; } = true; From 0ea6451c49901046bdb4e6f118fd320264f69bde Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 6 May 2026 00:02:19 -0700 Subject: [PATCH 8/8] Use platform-aware comparer in GetDefaultEnvironmentVariables() OrdinalIgnoreCase on Windows to match ProcessStartInfo.Environment behavior, Ordinal on Unix where env var names are case-sensitive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/StdioClientTransportOptions.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs index 1ee4d6988..6fe171dff 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs @@ -66,7 +66,10 @@ public sealed class StdioClientTransportOptions ? s_defaultWindowsVars : s_defaultUnixVars; - var result = new Dictionary(StringComparer.Ordinal); + var result = new Dictionary( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); foreach (var name in names) { var value = Environment.GetEnvironmentVariable(name);