test(mcp): environment, redaction and end to end through the core

The TZ test checks TZ is absent on Windows: the process policy never sets TZ there (process-environment-hook spec §3). Spec testing bullet and plan updated to match.
This commit is contained in:
EugeneTes
2026-09-26 20:54:20 +02:00
parent 2c5d5dfcb4
commit 963301efc4
5 changed files with 250 additions and 2 deletions

View File

@@ -4010,7 +4010,9 @@ public class McpEnvironmentTests
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")));
var env = await ServerEnvironmentAsync(host, TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"));
Assert.Equal("Asia/Tokyo", env["TZ"]);
// The policy skips TZ on Windows, where the OS doesn't use it (process-environment-hook spec §3).
if (OperatingSystem.IsWindows()) Assert.False(env.ContainsKey("TZ"));
else Assert.Equal("Asia/Tokyo", env["TZ"]);
}
private sealed class SpawnWatcher : IExtension

View File

@@ -365,7 +365,7 @@ No test touches the network, npm or a real browser.
- environment (`FAKE_MCP_MODE=env`, a tool that returns its environment): with `OPENAI_API_KEY` and `GITHUB_TOKEN`
in the injected host snapshot, neither reaches the server; a server `env` entry `GITHUB_TOKEN: ${env:GITHUB_TOKEN}`
delivers it (explicit beats the deny-list); discovery and the session process agree; `TZ` is the session's zone
for the session process;
for the session process (on Unix; the policy never sets `TZ` on Windows, so there the test checks it's absent);
- results: text, image → `ImageContent`, oversized image replaced, `isError` → error tool result with content,
output truncation note;
- redaction, end to end through the core: the `env` tool's output with a `GITHUB_TOKEN` configured as a secret

View File

@@ -0,0 +1,80 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpEndToEndTests
{
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
[Fact]
public async Task Image_tool_result_reaches_the_model_as_image_content()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server()));
host.Faux.Enqueue(FauxTurn.Tool("image", new { }), FauxTurn.Say("I see a pixel"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("show me");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.False(result.IsError);
Assert.Equal(new ImageContent(FakeServerProgram.Png1x1, "image/png"), Assert.Single(result.Content));
Assert.Equal("fake", result.Details!.Value.GetProperty("server").GetString());
var sent = host.Faux.Requests[1].Messages.OfType<ToolResultMessage>().Single();
Assert.Contains(sent.Content, c => c is ImageContent);
}
[Fact]
public async Task Fail_tool_becomes_an_error_result_with_the_server_content()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server()));
host.Faux.Enqueue(FauxTurn.Tool("fail", new { }), FauxTurn.Say("it failed"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("try");
var result = session.Messages.OfType<ToolResultMessage>().Single();
Assert.True(result.IsError);
Assert.Contains("fake failure: nothing works", result.Text, StringComparison.Ordinal);
Assert.Contains(result.Content, c => c is ImageContent);
}
[Fact]
public async Task Preset_runs_against_a_fake_playwright_with_workspace_paths()
{
var log = Path.Combine(Path.GetTempPath(), $"volcanic-mcp-preset-{Guid.NewGuid():N}.log");
try
{
await using var host = new McpTestHost(
new McpOptions { Playwright = new PlaywrightOptions { Enabled = true, Command = FakeMcp.AppHostPath } },
env => { env["FAKE_MCP_MODE"] = "count-starts"; env["FAKE_MCP_LOG"] = log; });
host.Faux.Enqueue(
FauxTurn.Tool("browser_navigate", new { url = "https://example.com" }),
FauxTurn.Tool("browser_take_screenshot", new { filename = "browser/shot.png" }),
FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("screenshot example.com");
var texts = session.Messages.OfType<ToolResultMessage>().Select(m => m.Text).ToList();
Assert.Equal(["navigated to https://example.com", "saved browser/shot.png"], texts);
Assert.True(File.Exists(Path.Combine(host.Workspace, "browser", "shot.png")));
var start = FakeMcp.Starts(log)[1];
Assert.Equal(Path.GetFullPath(host.Workspace), Path.GetFullPath(start.GetProperty("cwd").GetString()!));
var args = start.GetProperty("args").EnumerateArray().Select(a => a.GetString()).ToList();
Assert.DoesNotContain("-y", args);
Assert.Equal("chromium", args[args.IndexOf("--browser") + 1]);
Assert.Equal($"{host.Workspace}/browser", args[args.IndexOf("--output-dir") + 1]);
Assert.Equal($"{host.Workspace}/.volcanic/browser-profile", args[args.IndexOf("--user-data-dir") + 1]);
Assert.Contains("--headless", args);
}
finally
{
File.Delete(log);
}
}
}

View File

@@ -0,0 +1,114 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpEnvironmentTests
{
private const string OpenAiKey = "sk-openai-test-value-123";
private const string GithubToken = "ghp_test_token_value_456";
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
private static void Secrets(Dictionary<string, string?> env)
{
env["OPENAI_API_KEY"] = OpenAiKey;
env["GITHUB_TOKEN"] = GithubToken;
}
private static Dictionary<string, string> Parse(string text) =>
text.Split('\n').Select(l => l.Split('=', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
private static async Task<Dictionary<string, string>> ServerEnvironmentAsync(McpTestHost host, TimeZoneInfo? zone = null)
{
host.Faux.Enqueue(FauxTurn.Tool("env", new { }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync(zone);
await session.PromptAsync("go");
return Parse(session.Messages.OfType<ToolResultMessage>().Last().Text);
}
[Fact]
public async Task Host_secrets_do_not_reach_the_server()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")), Secrets);
var env = await ServerEnvironmentAsync(host);
Assert.False(env.ContainsKey("OPENAI_API_KEY"));
Assert.False(env.ContainsKey("GITHUB_TOKEN"));
Assert.True(env.ContainsKey("PATH"));
Assert.Equal("env", env["FAKE_MCP_MODE"]);
}
[Fact]
public async Task Explicit_env_entry_delivers_a_denied_variable()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env", configure: c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Secrets);
var env = await ServerEnvironmentAsync(host);
Assert.Equal(GithubToken, env["GITHUB_TOKEN"]);
Assert.False(env.ContainsKey("OPENAI_API_KEY"));
}
[Fact]
public async Task Discovery_and_session_process_get_the_same_environment()
{
await using var host = new McpTestHost(h => Fake(FakeMcp.Server("env,count-starts", h.LogPath, c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Secrets);
await ServerEnvironmentAsync(host);
var starts = FakeMcp.Starts(host.LogPath);
Assert.Equal(2, starts.Count);
Dictionary<string, string?> Env(int i) => starts[i].GetProperty("env").EnumerateObject()
.Where(p => p.Name != "TZ").ToDictionary(p => p.Name, p => p.Value.GetString());
var discovery = Env(0);
var session = Env(1);
Assert.Equal(discovery.OrderBy(kv => kv.Key, StringComparer.Ordinal), session.OrderBy(kv => kv.Key, StringComparer.Ordinal));
Assert.Equal(GithubToken, session["GITHUB_TOKEN"]);
Assert.False(session.ContainsKey("OPENAI_API_KEY"));
}
[Fact]
public async Task Session_process_gets_the_session_time_zone()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")));
var env = await ServerEnvironmentAsync(host, TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo"));
// The policy skips TZ on Windows, where the OS doesn't use it (process-environment-hook spec §3).
if (OperatingSystem.IsWindows()) Assert.False(env.ContainsKey("TZ"));
else Assert.Equal("Asia/Tokyo", env["TZ"]);
}
private sealed class SpawnWatcher : IExtension
{
public ProcessSpawnContext? Seen { get; private set; }
public string Name => "spawn-watcher";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
api.On<ProcessSpawnEvent>((e, _, _) =>
{
if (e.Context.Purpose != ProcessPurpose.McpServer) return ValueTask.CompletedTask;
Seen = e.Context;
e.Context = e.Context with { Environment = new Dictionary<string, string>(e.Context.Environment) { ["FROM_HANDLER"] = "yes" } };
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}
}
[Fact]
public async Task Process_spawn_event_handlers_see_session_process_spawns()
{
var watcher = new SpawnWatcher();
await using var host = new McpTestHost(Fake(FakeMcp.Server("env")), extra: [watcher]);
var env = await ServerEnvironmentAsync(host);
Assert.Equal("yes", env["FROM_HANDLER"]);
Assert.Equal("fake", watcher.Seen!.Origin);
Assert.Equal(host.Workspace, watcher.Seen.Cwd);
}
}

View File

@@ -0,0 +1,52 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Tests.Mcp.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.Mcp;
public class McpRedactionTests
{
private const string GithubToken = "ghp_test_token_value_456";
private static McpOptions Fake(McpServerConfig config) => new() { Servers = { ["fake"] = config } };
private static void Token(Dictionary<string, string?> env) => env["GITHUB_TOKEN"] = GithubToken;
[Fact]
public async Task Env_tool_output_is_redacted_before_the_next_request()
{
await using var host = new McpTestHost(Fake(FakeMcp.Server("env", configure: c => c.Env["GITHUB_TOKEN"] = "${env:GITHUB_TOKEN}")), Token, [GithubToken]);
host.Faux.Enqueue(FauxTurn.Tool("env", new { }), FauxTurn.Say("done"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
var sent = host.Faux.Requests[1].Messages.OfType<ToolResultMessage>().Last().Text;
Assert.Contains("GITHUB_TOKEN=[REDACTED]", sent, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, sent, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, session.Messages.OfType<ToolResultMessage>().Last().Text, StringComparison.Ordinal);
}
[Fact]
public async Task Exit_error_stderr_tail_is_redacted()
{
await using var host = new McpTestHost(
h => Fake(FakeMcp.Server("die-after-first-call,count-starts", h.LogPath, c => c.Env["FAKE_MCP_DIE_MESSAGE"] = "${env:GITHUB_TOKEN}")),
Token, [GithubToken]);
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "1" }), FauxTurn.Say("a"));
var session = await host.CreateSessionAsync();
await session.PromptAsync("first");
var pid = FakeMcp.Starts(host.LogPath)[1].GetProperty("pid").GetInt32();
await FakeMcp.WaitForAsync(() => FakeMcp.HasExited(pid), "the fake to die");
host.Faux.Enqueue(FauxTurn.Tool("echo", new { text = "2" }), FauxTurn.Say("b"));
await session.PromptAsync("second");
var error = session.Messages.OfType<ToolResultMessage>().Last();
Assert.True(error.IsError);
Assert.Contains("fake MCP server exited (3)", error.Text, StringComparison.Ordinal);
Assert.Contains("dying: [REDACTED]", error.Text, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, error.Text, StringComparison.Ordinal);
Assert.DoesNotContain(GithubToken, host.Faux.Requests[^1].Messages.OfType<ToolResultMessage>().Last().Text, StringComparison.Ordinal);
}
}