Skip to content
Merged
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
62 changes: 60 additions & 2 deletions src/Netclaw.Daemon.Tests/Mcp/McpSmokeHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Netclaw.Actors.Tools;
using Netclaw.Configuration;
using Netclaw.Configuration.Secrets;
using Netclaw.Daemon.Mcp;
using Netclaw.Tools;
using Xunit;

namespace Netclaw.Daemon.Tests.Mcp;

Expand All @@ -31,9 +33,29 @@ private McpSmokeHarness(McpClientManager manager, McpOAuthFlowBroker flowBroker)

public McpClientManager Manager { get; }

/// <summary>
/// Asserts that the named MCP server reached the <see cref="McpConnectionState.Connected"/>
/// state after <see cref="Manager.StartAsync"/> completed. `StartAsync` awaits the whole
/// connect attempt — either tools are published to the registry or a failure status with
/// the underlying error is published — so there is nothing to poll: this is the
/// deterministic completion signal. Asserting on it turns an intermittent Windows CI
/// connect failure (previously a bare `Assert.NotNull` null on the tool lookup) into a
/// failure that carries the manager's actual error message.
/// </summary>
public void AssertConnected(string serverName)
{
var status = Manager.GetServerStatuses().GetValueOrDefault(new McpServerName(serverName));
Assert.NotNull(status);
Assert.True(
status.State is McpConnectionState.Connected,
$"MCP server '{serverName}' failed to connect: state={status.State}, " +
$"error={status.ErrorMessage ?? "(none)"}");
}

public static McpSmokeHarness Create(
Dictionary<string, McpServerEntry> serverEntries,
ToolRegistry registry)
ToolRegistry registry,
ITestOutputHelper? output = null)
{
var paths = new NetclawPaths(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
paths.EnsureDirectoriesExist();
Expand All @@ -54,11 +76,47 @@ public static McpSmokeHarness Create(
NullNotificationSink.Instance,
TimeProvider.System,
new McpClientRuntime(),
NullLogger<McpClientManager>.Instance,
// Real logger wired to test output: when a connect fails the manager
// logs the full exception via ReportConnectionFailure, and NullLogger
// was discarding it — leaving only the generic status ErrorMessage.
output is null
? NullLogger<McpClientManager>.Instance
: new TestOutputLogger<McpClientManager>(output),
new SessionConfig());
return new McpSmokeHarness(manager, flowBroker);
}

/// <summary>
/// Minimal <see cref="ILogger{T}"/> that forwards to xunit test output so
/// the manager's own diagnostics (including the full connect-failure
/// exception) show up in the CI log when a smoke test fails.
/// </summary>
private sealed class TestOutputLogger<T> : ILogger<T>
{
private readonly ITestOutputHelper _output;

public TestOutputLogger(ITestOutputHelper output) => _output = output;

public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
var message = formatter(state, exception);
_output.WriteLine($"[{logLevel}] {message}");
if (exception is not null)
_output.WriteLine(exception.ToString());
}
}

public async ValueTask DisposeAsync()
{
await Manager.StopAsync(CancellationToken.None);
Expand Down
44 changes: 40 additions & 4 deletions src/Netclaw.Daemon.Tests/Mcp/SmokeMcpServerHttpHeaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ namespace Netclaw.Daemon.Tests.Mcp;
/// </summary>
public sealed class SmokeMcpServerHttpHeaderTests
{
private readonly ITestOutputHelper _output;

public SmokeMcpServerHttpHeaderTests(ITestOutputHelper output) => _output = output;

[Fact]
public async Task ConfiguredHeader_IsAttachedToOutboundMcpRequest()
{
Expand All @@ -54,9 +58,17 @@ public async Task ConfiguredHeader_IsAttachedToOutboundMcpRequest()

var registry = new ToolRegistry();
await using var harness = McpSmokeHarness.Create(
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry);
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry,
_output);

await harness.Manager.StartAsync(ct);
// Deterministic completion signal: StartAsync awaits the whole connect
// attempt, so by the time it returns the manager has either published
// tools or recorded the failure with its real error message. Asserting
// Connected here turns an intermittent Windows CI connect failure into
// a failure that names the underlying error instead of a bare null on
// the tool lookup below.
harness.AssertConnected("smoke-http");

var lastAuthHeader = registry.GetAllRegistrations()
.Select(r => r.Tool)
Expand Down Expand Up @@ -90,9 +102,17 @@ public async Task Netclaw_user_agent_and_component_headers_are_attached_to_mcp_r

var registry = new ToolRegistry();
await using var harness = McpSmokeHarness.Create(
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry);
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry,
_output);

await harness.Manager.StartAsync(ct);
// Deterministic completion signal: StartAsync awaits the whole connect
// attempt, so by the time it returns the manager has either published
// tools or recorded the failure with its real error message. Asserting
// Connected here turns an intermittent Windows CI connect failure into
// a failure that names the underlying error instead of a bare null on
// the tool lookup below.
harness.AssertConnected("smoke-http");

var lastUserAgent = registry.GetAllRegistrations()
.Select(r => r.Tool)
Expand Down Expand Up @@ -138,9 +158,17 @@ public async Task NoConfiguredHeader_ResultsInNoAuthorizationHeaderOnTheWire()

var registry = new ToolRegistry();
await using var harness = McpSmokeHarness.Create(
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry);
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry,
_output);

await harness.Manager.StartAsync(ct);
// Deterministic completion signal: StartAsync awaits the whole connect
// attempt, so by the time it returns the manager has either published
// tools or recorded the failure with its real error message. Asserting
// Connected here turns an intermittent Windows CI connect failure into
// a failure that names the underlying error instead of a bare null on
// the tool lookup below.
harness.AssertConnected("smoke-http");

var lastAuthHeader = registry.GetAllRegistrations()
.Select(r => r.Tool)
Expand Down Expand Up @@ -185,9 +213,17 @@ public async Task ConfiguredHeader_WhenOAuthProbeReturnsMetadata_StillReachesSer

var registry = new ToolRegistry();
await using var harness = McpSmokeHarness.Create(
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry);
new Dictionary<string, McpServerEntry> { ["smoke-http"] = entry }, registry,
_output);

await harness.Manager.StartAsync(ct);
// Deterministic completion signal: StartAsync awaits the whole connect
// attempt, so by the time it returns the manager has either published
// tools or recorded the failure with its real error message. Asserting
// Connected here turns an intermittent Windows CI connect failure into
// a failure that names the underlying error instead of a bare null on
// the tool lookup below.
harness.AssertConnected("smoke-http");

var lastAuthHeader = registry.GetAllRegistrations()
.Select(r => r.Tool)
Expand Down
Loading