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
13 changes: 13 additions & 0 deletions src/Netclaw.Cli.Tests/Mcp/McpOAuthEndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ public async ValueTask<McpClientInitialization> InitializeAsync(
return new McpClientInitialization(tools.Cast<AIFunction>().ToList());
}

public ValueTask<IReadOnlyList<AIFunction>> ListToolsAsync(
McpClient client,
CancellationToken cancellationToken)
=> ListToolsCoreAsync(client, cancellationToken);

private async ValueTask<IReadOnlyList<AIFunction>> ListToolsCoreAsync(
McpClient client,
CancellationToken cancellationToken)
{
var tools = await client.ListToolsAsync(cancellationToken: cancellationToken);
return tools.Cast<AIFunction>().ToList();
}

public ValueTask<object?> InvokeAsync(
AIFunction function,
AIFunctionArguments? arguments,
Expand Down
249 changes: 249 additions & 0 deletions src/Netclaw.Daemon.Tests/Mcp/McpCatalogRefreshTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
// -----------------------------------------------------------------------
// <copyright file="McpCatalogRefreshTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Time.Testing;
using Netclaw.Daemon.Mcp;
using Xunit;

namespace Netclaw.Daemon.Tests.Mcp;

public sealed class McpCatalogRefreshTests
{
private static readonly McpServerName ServerName = new("test");
private static readonly DateTimeOffset InitialTime = DateTimeOffset.Parse("2026-07-22T12:00:00Z");

[Fact]
public async Task CatalogChange_RepublishesSnapshotAndGeneration()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("old_tool"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

// Connect marked the catalog fresh; the throttle must elapse before a refresh.
time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ToolNames = ["old_tool", "new_tool"];
Assert.True(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

var snapshot = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(2, snapshot.Generation);
Assert.Equal(2, snapshot.ToolFunctions.Count);
Assert.Equal(2, snapshot.Status.ToolCount);
Assert.Equal(1, plan.RefreshCount);
Assert.Equal(1, runtime.CreateCount); // no reconnect
AssertPublishedTools(harness, "new_tool", "old_tool");
}

[Fact]
public async Task NoCatalogChange_DoesNotBumpGeneration()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("stable_tool"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

time.Advance(McpClientManager.CatalogRefreshInterval);
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

var snapshot = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(1, snapshot.Generation);
Assert.Equal(1, plan.RefreshCount);
Assert.Equal(1, runtime.CreateCount);
AssertPublishedTools(harness, "stable_tool");
}

[Fact]
public async Task RefreshIsThrottledWithinInterval()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("tool_a"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

// First refresh immediately after connect is throttled (connect marked it fresh).
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ToolNames = ["tool_a", "tool_b"];
Assert.True(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

// Second refresh within the interval is throttled even though the catalog changed.
plan.ToolNames = ["tool_a", "tool_b", "tool_c"];
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

var snapshot = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(2, snapshot.Generation);
Assert.Equal(1, plan.RefreshCount); // only the middle call actually re-listed
}

[Fact]
public async Task FailedRefresh_KeepsLastGoodCatalogAndGeneration()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("old_tool"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ListFailure = new InvalidOperationException("server blew up");
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Equal(1, plan.RefreshCount); // prove the failure path actually ran

var snapshot = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(1, snapshot.Generation);
Assert.Equal("old_tool", Assert.Single(snapshot.ToolFunctions).Key);
Assert.Equal(McpConnectionState.Connected, snapshot.Status.State);
AssertPublishedTools(harness, "old_tool");
}

[Fact]
public async Task FailedRefresh_RollsBackThrottleSoNextTickRetries()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("tool_a"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ListFailure = new InvalidOperationException("transient");
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Equal(1, plan.RefreshCount);

// The claim was rolled back, so a 30s advance allows an immediate retry rather
// than forcing a 5-minute wait. Catalog is unchanged, so no generation bump.
plan.ListFailure = null;
time.Advance(TimeSpan.FromSeconds(30));
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Equal(2, plan.RefreshCount);
Assert.Equal(1, harness.Manager.GetSnapshot(ServerName)?.Generation);
}

[Fact]
public async Task EmptyCatalogRefresh_KeepsLastGoodTools()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("tool_a", "tool_b"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ToolNames = []; // server now reports no tools
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));

var snapshot = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(1, snapshot.Generation);
Assert.Equal(2, snapshot.ToolFunctions.Count);
Assert.Equal(McpConnectionState.Connected, snapshot.Status.State);
AssertPublishedTools(harness, "tool_a", "tool_b");
}

[Fact]
public async Task EmptyCatalogRefresh_RollsBackThrottleSoNextTickRetries()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
var plan = runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("tool_a", "tool_b"));
var time = new FakeTimeProvider(InitialTime);
await using var harness = CreateHarness(runtime, time);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

time.Advance(McpClientManager.CatalogRefreshInterval);
plan.ToolNames = []; // server now reports no tools
Assert.False(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Equal(1, plan.RefreshCount);

// The last-good, previously non-empty catalog stays published.
var snapshotAfterEmpty = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(1, snapshotAfterEmpty.Generation);
Assert.Equal(2, snapshotAfterEmpty.ToolFunctions.Count);
AssertPublishedTools(harness, "tool_a", "tool_b");

// The claim was rolled back, so a 30s advance allows an immediate retry rather
// than forcing a 5-minute wait. The server recovers with a changed catalog, so
// the re-list runs and the snapshot generation bumps.
plan.ToolNames = ["tool_a", "tool_b", "tool_c"];
time.Advance(TimeSpan.FromSeconds(30));
Assert.True(await harness.Manager.TryRefreshCatalogAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Equal(2, plan.RefreshCount);

var snapshotAfterRecovery = Assert.IsType<McpServerSnapshot>(harness.Manager.GetSnapshot(ServerName));
Assert.Equal(2, snapshotAfterRecovery.Generation);
Assert.Equal(3, snapshotAfterRecovery.ToolFunctions.Count);
AssertPublishedTools(harness, "tool_a", "tool_b", "tool_c");
}

[Fact]
public async Task RefreshOnUnknownServer_IsNoOp()
{
var runtime = new McpClientManagerLifecycleTests.ControlledMcpClientRuntime();
runtime.Enqueue(new McpClientManagerLifecycleTests.ClientPlan("tool_a"));
await using var harness = CreateHarness(runtime);
await harness.Manager.StartAsync(TestContext.Current.CancellationToken);

Assert.False(await harness.Manager.TryRefreshCatalogAsync(new McpServerName("missing"), TestContext.Current.CancellationToken));
}

[Fact]
public void Fingerprint_IgnoresToolOrder()
{
var a = AIFunctionFactory.Create(() => "unused", name: "tool_a", description: "desc");
var b = AIFunctionFactory.Create(() => "unused", name: "tool_b", description: "desc");

Assert.Equal(
McpClientManager.ComputeCatalogFingerprint([a, b]),
McpClientManager.ComputeCatalogFingerprint([b, a]));
}

[Fact]
public void Fingerprint_ChangesOnDescriptionOrToolAdd()
{
var baseline = AIFunctionFactory.Create(() => "unused", name: "tool", description: "desc");
var newDescription = AIFunctionFactory.Create(() => "unused", name: "tool", description: "changed");
var addedTool = AIFunctionFactory.Create(() => "unused", name: "other", description: "desc");

var baselineHash = McpClientManager.ComputeCatalogFingerprint([baseline]);
Assert.NotEqual(baselineHash, McpClientManager.ComputeCatalogFingerprint([newDescription]));
Assert.NotEqual(baselineHash, McpClientManager.ComputeCatalogFingerprint([baseline, addedTool]));
}

[Fact]
public void CanonicalSchema_IgnoresKeyOrderAndWhitespace()
{
var a = JsonDocument.Parse("""{"z":1,"a":{"y":true,"b":"x"}}""").RootElement;
var b = JsonDocument.Parse(""" { "a": { "b": "x", "y": true }, "z": 1 } """).RootElement;

Assert.Equal(McpClientManager.CanonicalSchema(a), McpClientManager.CanonicalSchema(b));
}

[Fact]
public void CanonicalSchema_ChangesOnSchemaEdit()
{
var a = JsonDocument.Parse("""{"type":"object","properties":{"a":{"type":"number"}}}""").RootElement;
var b = JsonDocument.Parse("""{"type":"object","properties":{"a":{"type":"string"}}}""").RootElement;

Assert.NotEqual(McpClientManager.CanonicalSchema(a), McpClientManager.CanonicalSchema(b));
}

private static void AssertPublishedTools(McpClientManagerLifecycleTests.ManagerHarness harness, params string[] expected)
{
Assert.Equal(expected, harness.Manager.GetToolNames(ServerName));
Assert.Equal(expected.Length, harness.Manager.GetServerStatuses()[ServerName].ToolCount);
}

private static McpClientManagerLifecycleTests.ManagerHarness CreateHarness(McpClientManagerLifecycleTests.ControlledMcpClientRuntime runtime)
=> new(runtime, new FakeTimeProvider(InitialTime));

private static McpClientManagerLifecycleTests.ManagerHarness CreateHarness(
McpClientManagerLifecycleTests.ControlledMcpClientRuntime runtime,
FakeTimeProvider time)
=> new(runtime, time);
}
40 changes: 35 additions & 5 deletions src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -487,12 +487,19 @@ private static ManagerHarness CreateHarness(
IOperationalNotificationSink notificationSink)
=> new(runtime, timeProvider, notificationSink);

private sealed class ManagerHarness : IAsyncDisposable
internal sealed class ManagerHarness : IAsyncDisposable
{
private readonly McpOAuthFlowBroker _flowBroker;
private bool _stopFailureObserved;
private bool _managerDisposed;

public ManagerHarness(
ControlledMcpClientRuntime runtime,
FakeTimeProvider timeProvider)
: this(runtime, timeProvider, NullNotificationSink.Instance)
{
}

public ManagerHarness(
ControlledMcpClientRuntime runtime,
FakeTimeProvider timeProvider,
Expand Down Expand Up @@ -555,7 +562,7 @@ public async ValueTask DisposeAsync()
}
}

private sealed class ControlledMcpClientRuntime : IMcpClientRuntime
internal sealed class ControlledMcpClientRuntime : IMcpClientRuntime
{
private readonly ConcurrentQueue<ClientPlan> _plans = new();
private readonly ConcurrentDictionary<McpClient, ClientPlan> _clients = new();
Expand Down Expand Up @@ -598,6 +605,23 @@ public async ValueTask<McpClientInitialization> InitializeAsync(
if (plan.Initialize is not null)
await plan.Initialize(cancellationToken);

var functions = BuildFunctions(plan);
return new McpClientInitialization(functions.Values.ToList());
}

public ValueTask<IReadOnlyList<AIFunction>> ListToolsAsync(
McpClient client,
CancellationToken cancellationToken)
{
var plan = _clients[client];
Interlocked.Increment(ref plan.RefreshCountStorage);
if (plan.ListFailure is not null)
return ValueTask.FromException<IReadOnlyList<AIFunction>>(plan.ListFailure);
return ValueTask.FromResult<IReadOnlyList<AIFunction>>(BuildFunctions(plan).Values.ToList());
}

private IReadOnlyDictionary<string, AIFunction> BuildFunctions(ClientPlan plan)
{
var functions = new Dictionary<string, AIFunction>(StringComparer.OrdinalIgnoreCase);
foreach (var name in plan.ToolNames)
{
Expand All @@ -606,7 +630,7 @@ public async ValueTask<McpClientInitialization> InitializeAsync(
_functions[function] = plan;
}

return new McpClientInitialization(functions.Values.ToList());
return functions;
}

public async ValueTask<object?> InvokeAsync(
Expand All @@ -632,16 +656,18 @@ public ValueTask DisposeAsync(McpClient client)
}
}

private sealed class ClientPlan(params string[] toolNames)
internal sealed class ClientPlan(params string[] toolNames)
{
public string[] ToolNames { get; } = toolNames;
public string[] ToolNames { get; set; } = toolNames;

public Func<CancellationToken, Task>? Initialize { get; init; }

public Func<int, CancellationToken, Task<object?>>? Invoke { get; init; }

public Exception? DisposeFailure { get; init; }

public Exception? ListFailure { get; set; }

public TaskCompletionSource Created { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);

Expand All @@ -654,9 +680,13 @@ private sealed class ClientPlan(params string[] toolNames)

public int DisposeCountStorage;

public int RefreshCountStorage;

public int InvocationCount => Volatile.Read(ref InvocationCountStorage);

public int DisposeCount => Volatile.Read(ref DisposeCountStorage);

public int RefreshCount => Volatile.Read(ref RefreshCountStorage);
}

private sealed class RecordingNotificationSink : IOperationalNotificationSink
Expand Down
Loading
Loading