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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user