// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using Moq;
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
///
/// Verifies that resolving a per-request toolbox marker via
/// is strictly request-scoped: a marker
/// referenced by one request must never leak its tools into — or raise a consent prompt on — a
/// request that did not reference it, and must not flip the container-global startup state.
///
[Collection(FoundryProjectEndpointEnvFixture.Name)]
public class FoundryToolboxMarkerScopingTests
{
private static async Task CreateStartedServiceAsync(
Func> opener)
{
// Non-strict, no pre-registered toolboxes: StartAsync only resolves the endpoint (so the
// marker path can run) and reports Healthy. The opener seam replaces the network I/O.
var options = new FoundryToolboxOptions
{
StrictMode = false,
EndpointOverride = "http://127.0.0.1:1/unused",
};
var service = new FoundryToolboxService(
Options.Create(options),
Mock.Of())
{
ToolboxOpener = opener,
};
await service.StartAsync(CancellationToken.None);
// Sanity: a clean start with no pre-registered toolboxes is Healthy and global-empty.
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
Assert.Empty(service.ConsentRequiredToolboxNames);
Assert.Empty(service.Tools);
return service;
}
[Fact]
public async Task GetToolboxToolsAsync_MarkerConsent_IsRequestScopedAndDoesNotMutateGlobalStateAsync()
{
// Arrange: the opener reports CONSENT_REQUIRED for every marker.
await using var service = await CreateStartedServiceAsync(
(name, _, _) => Task.FromResult(
new FoundryToolboxService.ToolboxOpenResult(
Cached: null,
Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")])));
// Act: request A references marker-a and hits consent.
var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
// Assert: the consent is returned to THIS caller, with no tools.
Assert.Empty(resolutionA.Tools);
Assert.Single(resolutionA.Consents);
Assert.Equal("https://consent.example/marker-a", resolutionA.Consents[0].ConsentUrl);
// Assert: container-global state is unchanged. The marker consent did not get recorded in
// ConsentRequiredToolboxNames and did not flip StartupStatus to ConsentRequired, so a request
// with no marker (which reads StartupStatus / Tools) is unaffected.
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
Assert.Empty(service.ConsentRequiredToolboxNames);
Assert.Empty(service.Tools);
// Act: a different request references marker-b. Its consent must not accumulate globally.
var resolutionB = await service.GetToolboxToolsAsync("marker-b", version: null, CancellationToken.None);
// Assert: still request-scoped, still no global mutation.
Assert.Single(resolutionB.Consents);
Assert.Equal("https://consent.example/marker-b", resolutionB.Consents[0].ConsentUrl);
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
Assert.Empty(service.ConsentRequiredToolboxNames);
Assert.Empty(service.Tools);
}
[Fact]
public async Task GetToolboxToolsAsync_MarkerTools_AreReturnedToCallerNotInjectedGloballyAsync()
{
// Arrange: the opener resolves marker-a to one tool; any other marker stays consent-gated.
AITool markerTool = AIFunctionFactory.Create(() => "ok", name: "marker_a_tool");
await using var service = await CreateStartedServiceAsync(
(name, _, _) => Task.FromResult(
string.Equals(name, "marker-a", StringComparison.OrdinalIgnoreCase)
? new FoundryToolboxService.ToolboxOpenResult(
new FoundryToolboxService.CachedToolbox(Client: null, new HttpClient(), [markerTool]),
Consents: null)
: new FoundryToolboxService.ToolboxOpenResult(
Cached: null,
Consents: [new McpConsentInfo(name, $"{name}.tool", $"https://consent.example/{name}")])));
// Act: request A references marker-a and resolves its tool.
var resolutionA = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
// Assert: the tool is returned to THIS caller only.
Assert.Empty(resolutionA.Consents);
Assert.Single(resolutionA.Tools);
Assert.Same(markerTool, resolutionA.Tools[0]);
// Assert: the resolved marker tool was NOT merged into the service-wide Tools cache, so a
// later request with no marker (which only ever gets _toolboxService.Tools) sees nothing.
Assert.Empty(service.Tools);
// Act: re-resolving marker-a returns the cached tool (no second open), still request-scoped.
var resolutionAgain = await service.GetToolboxToolsAsync("marker-a", version: null, CancellationToken.None);
// Assert.
Assert.Single(resolutionAgain.Tools);
Assert.Same(markerTool, resolutionAgain.Tools[0]);
Assert.Empty(service.Tools);
}
}